@ziggs-ai/agent-sdk 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 — 2026-07-30
4
+
5
+ ### Changed (breaking)
6
+
7
+ - `task-update` / `task_set_result` accept `state` only — legacy `status` alias removed (ZIG-1057).
8
+ - Removed export `syncAggregateTaskViews`; read pending/collected/delegated via `allPendingSubtasks` / `allCollectedResults` / `allDelegatedAgreementIds`. Ctx aggregate mirrors stay empty.
9
+
3
10
  ## 0.4.0 — 2026-07-23
4
11
 
5
12
  ### Added
@@ -71,7 +71,10 @@ export interface AgentHostOptions {
71
71
  agentId?: string;
72
72
  name?: string | null;
73
73
  wsUrl?: string;
74
+ /** Store-facing one-liner. See DefineAgentInput.description. */
74
75
  description?: string;
76
+ /** Claude-brain system prompt (ZIG-1080); falls back to `description`. */
77
+ systemPrompt?: string;
75
78
  specialization?: string | null;
76
79
  tools?: ToolDefinition[];
77
80
  taskTools?: 'all' | 'none' | string[];
package/dist/AgentHost.js CHANGED
@@ -90,7 +90,12 @@ function createBackendServices(options) {
90
90
  const agentId = options.agentId;
91
91
  const artifactsClient = new ArtifactsClient(operatorKey, agentId);
92
92
  const contextReadClient = new ContextReadClient(operatorKey, agentId);
93
- const promptBuilder = new PromptBuilder({ description: options.description, specialization: options.specialization });
93
+ // ZIG-1080: the role text is the prompt when the author split the two; the
94
+ // store-facing `description` is only the fallback for agents that never did.
95
+ const promptBuilder = new PromptBuilder({
96
+ description: options.systemPrompt || options.description,
97
+ specialization: options.specialization,
98
+ });
94
99
  const taskService = new TaskService(operatorKey, agentId);
95
100
  const agreementService = new AgreementService(operatorKey, agentId);
96
101
  const telemetryClient = new TelemetryClient(operatorKey, agentId);
@@ -145,6 +150,7 @@ export class AgentHost {
145
150
  name: resolved.name || null,
146
151
  wsUrl: resolved.wsUrl,
147
152
  description: resolved.description || 'A helpful assistant',
153
+ systemPrompt: resolved.systemPrompt,
148
154
  specialization: resolved.specialization ?? null,
149
155
  tools: resolved.tools || [],
150
156
  taskTools: resolved.taskTools,
@@ -268,6 +274,7 @@ export class AgentHost {
268
274
  workflow,
269
275
  toolManager: services.toolManager,
270
276
  description: this.options.description,
277
+ systemPrompt: this.options.systemPrompt,
271
278
  specialization: this.options.specialization,
272
279
  model: resolveClaudeModel(this.options.model, this.options.services?.llm?.model),
273
280
  anthropicKey: this.options.anthropicKey,
@@ -21,7 +21,10 @@ export type CognitionKind = 'fsm' | 'claude-sdk';
21
21
  export interface ClaudeSdkAgentOptions {
22
22
  workflow: Workflow;
23
23
  toolManager: ToolManager;
24
+ /** Store-facing one-liner. Used as the role block only when `systemPrompt` is absent. */
24
25
  description: string;
26
+ /** The agent's system prompt (ZIG-1080). Preferred over `description` for the role block. */
27
+ systemPrompt?: string;
25
28
  specialization?: string | null;
26
29
  model: string;
27
30
  anthropicKey: string;
@@ -44,7 +47,7 @@ export interface ClaudeSdkAgentOptions {
44
47
  export declare class ClaudeSdkAgent {
45
48
  workflow: Workflow;
46
49
  toolManager: ToolManager;
47
- private description;
50
+ private rolePrompt;
48
51
  private specialization;
49
52
  private model;
50
53
  private anthropicKey;
@@ -155,7 +155,7 @@ function resolveOnBehalfAgreementId(rawEvent, contextSnap, agentId, sessionId) {
155
155
  export class ClaudeSdkAgent {
156
156
  workflow;
157
157
  toolManager;
158
- description;
158
+ rolePrompt;
159
159
  specialization;
160
160
  model;
161
161
  anthropicKey;
@@ -173,7 +173,10 @@ export class ClaudeSdkAgent {
173
173
  throw new Error('ClaudeSdkAgent: anthropicKey is required');
174
174
  this.workflow = opts.workflow;
175
175
  this.toolManager = opts.toolManager;
176
- this.description = opts.description;
176
+ // ZIG-1080: the prompt and the store blurb are two different strings. Agents
177
+ // written before the split still carry the prompt in `description`, so fall
178
+ // back to it rather than silently dropping their role.
179
+ this.rolePrompt = opts.systemPrompt || opts.description;
177
180
  this.specialization = opts.specialization ?? null;
178
181
  this.model = opts.model;
179
182
  this.anthropicKey = opts.anthropicKey;
@@ -259,7 +262,7 @@ export class ClaudeSdkAgent {
259
262
  }
260
263
  const roleBlock = [
261
264
  `## Your role`,
262
- this.description,
265
+ this.rolePrompt,
263
266
  this.specialization ? `Specialization: ${this.specialization}` : '',
264
267
  ].filter(Boolean).join('\n');
265
268
  const systemPrompt = `${CLAUDE_SDK_PROTOCOL}\n\n${roleBlock}`;
@@ -350,7 +353,7 @@ export class ClaudeSdkAgent {
350
353
  if (text) {
351
354
  // Ordinal drawn synchronously so the fire-and-forget below can't
352
355
  // reorder key assignment against other writes of this wake.
353
- const idempotencyKey = wakeKeys.artifactKey();
356
+ const idempotencyKey = wakeKeys.artifactKey({ kind: 'thought', text });
354
357
  void eff({ kind: 'record-event', sessionId: identity.sessionId, entry: { kind: 'thought', text }, idempotencyKey })
355
358
  .catch((err) => runtimeLog.warn('ClaudeSdkAgent', `record thought failed session=${identity.sessionId}: ${err.message}`));
356
359
  }
@@ -8,18 +8,37 @@ import { listGrantsTool } from '../server/tools/tier2/grantsTools.js';
8
8
  // messages cover "what woke me and what was just said"; the model can pass a
9
9
  // larger maxMessages explicitly when it genuinely needs deeper history.
10
10
  const DEFAULT_SNAPSHOT_MAX_MESSAGES = 12;
11
+ /**
12
+ * First usable string among the candidates, treating blank as absent.
13
+ *
14
+ * ZIG-1081: a model that does not know a value fills the field with `""` rather
15
+ * than omitting it — the schema shows `chatId`, the agent has no way to know it,
16
+ * so it sends an empty string. Plain `??` let that blank win over the lane
17
+ * default, and the very next line rejected it as missing. Every wake opened with
18
+ * a failed `context_snapshot` and a recovery turn.
19
+ */
20
+ function firstFilled(...candidates) {
21
+ for (const candidate of candidates) {
22
+ if (typeof candidate === 'string' && candidate.trim() !== '')
23
+ return candidate;
24
+ }
25
+ return undefined;
26
+ }
11
27
  // Named distinctly from ziggs-mcp's per-type `ziggs_context_read`: this is the
12
28
  // one-shot aggregate snapshot (grant-fenced server-side), not a cursored read.
13
29
  export const contextSnapshotTool = defineTool('context_snapshot', {
14
- chatId: { type: 'string', description: 'Chat/session id. Defaults to the current lane session.' },
30
+ chatId: { type: 'string', description: 'Omit this to snapshot the chat you were woken in — that is the usual case, and you do not need to know its id. Only pass a chatId to snapshot a DIFFERENT chat.' },
15
31
  maxMessages: { type: 'number', description: `Message history cap (default ${DEFAULT_SNAPSHOT_MAX_MESSAGES}). Pass a larger value when you need deeper history.` },
16
32
  }, async (args, ctx) => {
17
33
  const c = ctx;
18
34
  if (!c.eff)
19
35
  throw new Error('context_snapshot requires eff in tool context');
20
- const sessionId = args.chatId ?? c.chatId ?? c.sessionId;
21
- if (!sessionId)
22
- throw new Error('context_snapshot: chatId is required');
36
+ const sessionId = firstFilled(args.chatId, c.chatId, c.sessionId);
37
+ if (!sessionId) {
38
+ throw new Error('context_snapshot: this wake carries no chat to snapshot. Call it with no arguments ' +
39
+ 'to read the chat you were woken in; if this wake is on an agreement lane with no ' +
40
+ 'single chat, read the agreement instead and pass a chatId from one of its chats.');
41
+ }
23
42
  return c.eff({
24
43
  kind: 'read-context',
25
44
  sessionId,
@@ -29,22 +48,24 @@ export const contextSnapshotTool = defineTool('context_snapshot', {
29
48
  });
30
49
  }, { description: 'Read the chat snapshot: the NEWEST maxMessages of history (chronological order), plus agreements, agents, and users for a session.' });
31
50
  export const chatSendTool = defineTool('chat_send', {
32
- chatId: { type: 'string', description: 'Chat id. Defaults to current session.' },
33
- receiverId: { type: 'string', description: 'Recipient user or agent id. Defaults to the sender of the event that woke you — omit when replying to them.' },
51
+ chatId: { type: 'string', description: 'Omit this to send in the chat you were woken in — that is the usual case. Only pass a chatId to send in a DIFFERENT chat.' },
52
+ receiverId: { type: 'string', description: 'Recipient user or agent id. Omit when replying to whoever woke you — that is the default.' },
34
53
  text: { type: 'string', required: true },
35
54
  idempotencyKey: { type: 'string', description: 'Reuse for retry-safe resend of the same logical message.' },
36
55
  }, async (args, ctx) => {
37
56
  const c = ctx;
38
57
  if (!c.eff)
39
58
  throw new Error('chat_send requires eff in tool context');
40
- const sessionId = args.chatId ?? c.chatId ?? c.sessionId;
41
- if (!sessionId)
42
- throw new Error('chat_send: chatId is required');
59
+ const sessionId = firstFilled(args.chatId, c.chatId, c.sessionId);
60
+ if (!sessionId) {
61
+ throw new Error('chat_send: this wake carries no chat to send in. Call it with no chatId to reply in ' +
62
+ 'the chat you were woken in, or pass the chatId of the chat you mean.');
63
+ }
43
64
  // Replying to whoever woke you is the overwhelmingly common case; making
44
65
  // the model spell it out cost a failed tool call + a full extra LLM turn
45
66
  // per reply. Batch/timer wakes carry no sender — those still require an
46
67
  // explicit receiverId.
47
- const receiverId = args.receiverId ?? c.senderId;
68
+ const receiverId = firstFilled(args.receiverId, c.senderId);
48
69
  if (!receiverId)
49
70
  throw new Error('chat_send: receiverId is required (this wake has no sender to default to)');
50
71
  // Default to the wake's content-derived key (ZIG-1045): a crash-replay that
@@ -99,12 +120,19 @@ export const listTasksTool = defineTool('task_list', {
99
120
  if (!c.agentId)
100
121
  throw new Error('task_list: agentId missing from tool context');
101
122
  const assignedTo = args.assignedToMe ? c.agentId : args.assignedTo;
123
+ // ZIG-1092: the lane fences this to the engagement's tasks. Without it the
124
+ // list is every task the agent's key can reach, across all its customers.
125
+ const laneId = c.chatId || c.sessionId || undefined;
102
126
  return listTasks({
103
127
  state: args.state,
104
128
  cursor: args.cursor,
105
129
  limit: typeof args.limit === 'number' ? args.limit : undefined,
106
130
  assignedTo,
107
- }, { operatorKey: c.operatorKey, agentId: c.agentId });
131
+ }, {
132
+ operatorKey: c.operatorKey,
133
+ agentId: c.agentId,
134
+ ...(laneId ? { laneId } : {}),
135
+ });
108
136
  }, {
109
137
  description: 'List tasks reachable by this agent (GET /tasks). Scope follows the operator key — same reach as chats and agreements. Optional state / assignee filters and cursor pagination. Use assignedToMe to list your own open work.',
110
138
  });
@@ -35,7 +35,10 @@ export function defineAgent(input) {
35
35
  // states/transitions are validated too.
36
36
  const workflow = injectTaskIntakeDefault(injectKickoffDefault({
37
37
  id: input.agentId,
38
- description: input.description,
38
+ // The workflow description is the agent's role text, which PromptBuilder
39
+ // renders into <self>. ZIG-1080 split that from the store-facing
40
+ // `description`: prefer the prompt when the author supplied one.
41
+ description: input.systemPrompt || input.description,
39
42
  initial: input.initial,
40
43
  states: input.states,
41
44
  }));
@@ -126,6 +126,17 @@ export class HistoryFormatter {
126
126
  return 'System';
127
127
  if (id === agentId)
128
128
  return 'You';
129
+ // ZIG-1137: prefer presentation face when the wire masks the principal.
130
+ const presentation = participant['presentation'];
131
+ const faceName = (typeof presentation?.['name'] === 'string' && presentation['name']) ||
132
+ (typeof presentation?.['persona']?.['name'] === 'string'
133
+ ? presentation['persona']['name']
134
+ : null);
135
+ if (faceName)
136
+ return `${faceName} (${id})`;
137
+ if (id.startsWith('rpb_') || id.startsWith('psn_')) {
138
+ return `${id} (presentation)`;
139
+ }
129
140
  return `${id} (${participant['type'] || 'unknown'})`;
130
141
  }
131
142
  formatOwner(task, agentId) {
package/dist/index.d.ts CHANGED
@@ -34,7 +34,7 @@ export { KICKOFF_STATE, injectKickoffDefault, buildKickoffState } from './runtim
34
34
  export { validateWorkflow, WorkflowValidationError } from './runtime/validateWorkflow.js';
35
35
  export { deriveRespondsToProposals, statesRespondToProposals, } from './runtime/deriveRespondsToProposals.js';
36
36
  export { outcomeFromEvent, outcomeFromActionResult, applyOutcomeToCtx } from './runtime/buildOutcome.js';
37
- export { LANE_BOOK_KEY, allCollectedResults, allDelegatedAgreementIds, allPendingSubtasks, syncAggregateTaskViews, } from './runtime/taskBookkeeping.js';
37
+ export { LANE_BOOK_KEY, allCollectedResults, allDelegatedAgreementIds, allPendingSubtasks, } from './runtime/taskBookkeeping.js';
38
38
  export { createWakeWriteKeys } from './runtime/wakeWriteKeys.js';
39
39
  export type { Outcome, OutcomeKind, Ctx, Identity, HistoryEntry, ToolResultEntry, PendingSubtask, CollectedSubtaskResult, TaskBookkeeping, Workflow, State, ParkedState, ThinkingState, MechanicalState, Action, Transition, PromptDef, ActionPrompt, Produces, ProducesFn, AgreementRef, TaskRef, ProposalRef, AgentConfig, DefineAgentInput, ServicesConfig, Effect, EffectKind, EffectResult, EffectResultMap, EffectHandler, LlmMessage, LlmToolCall, LlmToolSchema, LlmResponse, ContextSnapshot, ReadContextOpts, ToolCallResult, RecordedEntry, WakeWriteKeys, } from './types.js';
40
40
  export { default } from './AgentHost.js';
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@ export { KICKOFF_STATE, injectKickoffDefault, buildKickoffState } from './runtim
28
28
  export { validateWorkflow, WorkflowValidationError } from './runtime/validateWorkflow.js';
29
29
  export { deriveRespondsToProposals, statesRespondToProposals, } from './runtime/deriveRespondsToProposals.js';
30
30
  export { outcomeFromEvent, outcomeFromActionResult, applyOutcomeToCtx } from './runtime/buildOutcome.js';
31
- export { LANE_BOOK_KEY, allCollectedResults, allDelegatedAgreementIds, allPendingSubtasks, syncAggregateTaskViews, } from './runtime/taskBookkeeping.js';
31
+ export { LANE_BOOK_KEY, allCollectedResults, allDelegatedAgreementIds, allPendingSubtasks, } from './runtime/taskBookkeeping.js';
32
32
  export { createWakeWriteKeys } from './runtime/wakeWriteKeys.js';
33
33
  export { default } from './AgentHost.js';
34
34
  // ── Supporting modules ──────────────────────────────────────────────────────
@@ -71,6 +71,11 @@ interface WireMetadata {
71
71
  status?: string;
72
72
  };
73
73
  parties?: WireParties;
74
+ approvals?: Array<{
75
+ partyId?: string;
76
+ status?: string;
77
+ role?: string;
78
+ }>;
74
79
  };
75
80
  agreementId?: string;
76
81
  };
@@ -45,6 +45,27 @@ function isAlreadyActivatedProposalCreated(metadata) {
45
45
  const proposalStatus = agr.proposal?.status;
46
46
  return status === 'active' || proposalStatus === 'approved';
47
47
  }
48
+ /**
49
+ * ZIG-1078 — a `proposal_created` notification addressed to a party whose
50
+ * approval is PENDING is a consent REQUEST, not a status notice. The backend
51
+ * fans `proposal_created` out specifically to the pending approvers
52
+ * (ZIG-244), and parking it meant the hand-off provider (ZIG-1059) — or any
53
+ * agent whose consent a proposal awaits — was alerted and then never looked:
54
+ * the approval sat pending until a human nudged the chat. Same technique as
55
+ * isAlreadyActivatedProposalCreated (ZIG-1052): reclassify by what the
56
+ * embedded agreement SAYS rather than adding a new wake word.
57
+ */
58
+ function isPendingApprovalRequestNotification(metadata) {
59
+ if (wireOperation(metadata) !== 'proposal_created')
60
+ return false;
61
+ const receiverId = metadata.receiver?.id;
62
+ if (!receiverId)
63
+ return false;
64
+ const approvals = metadata.service?.agreement?.approvals;
65
+ if (!Array.isArray(approvals))
66
+ return false;
67
+ return approvals.some((a) => a && a.partyId === receiverId && a.status === 'pending');
68
+ }
48
69
  function isAgreementLifecycleNotification(metadata) {
49
70
  const entryType = metadata.entryType || 'message';
50
71
  if (entryType !== 'notification')
@@ -53,7 +74,8 @@ function isAgreementLifecycleNotification(metadata) {
53
74
  if (typeof operation === 'string' && PROPOSAL_RESOLVED_OPERATIONS.has(operation)) {
54
75
  return true;
55
76
  }
56
- return isAlreadyActivatedProposalCreated(metadata);
77
+ return (isAlreadyActivatedProposalCreated(metadata) ||
78
+ isPendingApprovalRequestNotification(metadata));
57
79
  }
58
80
  function isTerminalTaskState(state) {
59
81
  return state === 'completed' || state === 'failed' || state === 'cancelled';
@@ -19,7 +19,6 @@ export interface ProtocolPricingPayload {
19
19
  providerId?: string;
20
20
  executorId?: string;
21
21
  parentAgreementId?: string;
22
- parentTaskId?: string;
23
22
  [key: string]: unknown;
24
23
  }
25
24
  /**
@@ -36,8 +35,6 @@ export declare function applyFleetAgreementPricing(operation: 'agreement-propose
36
35
  * out unwidened by `ProtocolPricingPayload`'s `[key: string]: unknown` index.
37
36
  */
38
37
  export declare function applyFleetCounterPricing<T extends ProtocolPricingPayload>(payload: T, actingAgentId: string): T;
39
- /** User or agent direct-create / marketplace: default from provider specialist. */
40
- export declare function resolveDefaultPriceForProvider(providerId: string, description?: string | null, explicitPrice?: number | null): number | null;
41
38
  /**
42
39
  * Infer fleet actor when tool context omits agentId (ZIG-368 safety net).
43
40
  */
@@ -121,12 +121,6 @@ export function applyFleetCounterPricing(payload, actingAgentId) {
121
121
  return payload;
122
122
  return { ...payload, price: cents };
123
123
  }
124
- /** User or agent direct-create / marketplace: default from provider specialist. */
125
- export function resolveDefaultPriceForProvider(providerId, description, explicitPrice) {
126
- if (typeof explicitPrice === 'number')
127
- return explicitPrice;
128
- return resolveDefaultPriceCentsForExecutor(providerId, description);
129
- }
130
124
  /**
131
125
  * Infer fleet actor when tool context omits agentId (ZIG-368 safety net).
132
126
  */
@@ -1,6 +1,6 @@
1
1
  import { HistoryFormatter, AgreementFormatter } from '../formatters/index.js';
2
2
  import { validateContext } from '../cognition/validateContext.js';
3
- import { isBroadcastTarget } from '@ziggs-ai/api-client';
3
+ import { isBroadcastTarget, isOpaquePresentationRef } from '@ziggs-ai/api-client';
4
4
  import { LANE_BOOK_KEY, allCollectedResults, allDelegatedAgreementIds, allPendingSubtasks, } from './taskBookkeeping.js';
5
5
  /** Rough chars-per-token ratio for budgeting (English/JSON averages ~4). */
6
6
  const CHARS_PER_TOKEN = 4;
@@ -298,16 +298,20 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
298
298
  const pid = parties[field];
299
299
  if (!pid || pid === myAgentId)
300
300
  continue;
301
+ // Opaque presentation refs are not principal slots (ZIG-1137).
302
+ if (isOpaquePresentationRef(pid))
303
+ continue;
301
304
  if (!agreementParties.has(pid))
302
305
  agreementParties.set(pid, []);
303
306
  agreementParties.get(pid).push({ agreementId: agId ?? '', role });
304
307
  }
305
308
  }
306
- // Chat and transfer surfaces default to the full directory: every chat
307
- // counterparty can receive a chat message; every id can receive a transfer
308
- // by their principal id until per-wallet routing is wired through.
309
+ // Chat keeps the full directory — including opaque `rpb_*` / `psn_*` refs
310
+ // the persona layer may surface. Echo those as chat_send receiverId; the
311
+ // backend resolves them in-room. Transfer (and any account-id action) must
312
+ // never list opaque refs — lookup / wake / pay against them 400s (ZIG-1137).
309
313
  const chatIds = new Set([...userIds, ...agentIds]);
310
- const transferIds = new Set([...userIds, ...agentIds]);
314
+ const transferIds = new Set([...userIds, ...agentIds].filter((id) => !isOpaquePresentationRef(id)));
311
315
  const lines = ['<recipients>'];
312
316
  lines.push('<users>');
313
317
  if (userIds.size === 0)
@@ -1,4 +1,4 @@
1
- import { migrateLegacyTaskFields, syncAggregateTaskViews } from './taskBookkeeping.js';
1
+ import { migrateLegacyTaskFields } from './taskBookkeeping.js';
2
2
  /**
3
3
  * The one place a per-lane `Ctx` is constructed.
4
4
  *
@@ -18,16 +18,15 @@ import { migrateLegacyTaskFields, syncAggregateTaskViews } from './taskBookkeepi
18
18
  export function bootstrapCtx(identity, existing) {
19
19
  if (existing) {
20
20
  const legacy = existing;
21
- const ctx = {
21
+ return {
22
22
  ...existing,
23
23
  identity,
24
24
  tasks: migrateLegacyTaskFields(legacy),
25
+ // ZIG-1057: deprecated mirrors stay empty; use all*() readers.
25
26
  pendingSubtasks: {},
26
27
  collectedResults: [],
27
28
  delegatedAgreementIds: [],
28
29
  };
29
- syncAggregateTaskViews(ctx);
30
- return ctx;
31
30
  }
32
31
  return {
33
32
  identity,
@@ -1,6 +1,6 @@
1
1
  import { isProtocolToolName, mapProtocolToolToOperation } from '../tasks/protocolRegistry.js';
2
2
  import { getBatchEvents, isTaskResultRelevantToAgent } from '../context/batch.js';
3
- import { bookForDelegation, ensureTaskBook, findBookHoldingPending, removeTaskBook, syncAggregateTaskViews, } from './taskBookkeeping.js';
3
+ import { bookForDelegation, ensureTaskBook, findBookHoldingPending, removeTaskBook, } from './taskBookkeeping.js';
4
4
  // ── outcomeFromEvent: incoming wire event → Outcome ──────────────────────────
5
5
  /**
6
6
  * Converts a raw incoming event into the single Outcome that re-enters the
@@ -480,5 +480,4 @@ export function applyOutcomeToCtx(ctx, outcome) {
480
480
  default:
481
481
  break;
482
482
  }
483
- syncAggregateTaskViews(ctx);
484
483
  }
@@ -84,7 +84,7 @@ export async function runTurn(input) {
84
84
  if (s.length === 0)
85
85
  return;
86
86
  try {
87
- await eff({ kind: 'record-event', sessionId, entry: { kind: 'thought', text: s }, idempotencyKey: wakeKeys?.artifactKey() });
87
+ await eff({ kind: 'record-event', sessionId, entry: { kind: 'thought', text: s }, idempotencyKey: wakeKeys?.artifactKey({ kind: 'thought', text: s }) });
88
88
  }
89
89
  catch (err) {
90
90
  runtimeLog.warn('runTurn', `record-event:thought failed: ${err.message}`);
@@ -7,8 +7,6 @@ import type { Ctx, TaskBookkeeping } from '../types.js';
7
7
  export declare const LANE_BOOK_KEY = "__lane__";
8
8
  export declare function emptyTaskBook(agreementId?: string | null): TaskBookkeeping;
9
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
10
  /**
13
11
  * Book that receives task-delegated / subtask-finished when we know the parent
14
12
  * engagement: focus task if assigned, else the coordinator lane bucket.
@@ -26,10 +24,11 @@ export declare function hasDelegatedAgreement(ctx: Ctx, agreementId: string): bo
26
24
  * focused task. Leaves sibling entries (and the coordinator lane bucket) alone.
27
25
  */
28
26
  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
27
  /**
32
28
  * Migrate a pre-ZIG-977 persisted ctx (lane-level pendingSubtasks /
33
29
  * collectedResults / delegatedAgreementIds) into the tasks map.
30
+ * ZIG-1057: the deprecated lane aggregates are no longer auto-synced —
31
+ * readers use allPendingSubtasks / allCollectedResults / allDelegatedAgreementIds.
32
+ * This migrator stays so a session persisted before the tasks map still boots.
34
33
  */
35
34
  export declare function migrateLegacyTaskFields(existing: Record<string, unknown>): Record<string, TaskBookkeeping>;
@@ -25,12 +25,6 @@ export function ensureTaskBook(ctx, taskId, agreementId) {
25
25
  }
26
26
  return book;
27
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
28
  /**
35
29
  * Book that receives task-delegated / subtask-finished when we know the parent
36
30
  * engagement: focus task if assigned, else the coordinator lane bucket.
@@ -106,15 +100,12 @@ export function removeTaskBook(ctx, taskId) {
106
100
  ctx.activeAgreementId = nextId ? (ctx.tasks[nextId]?.agreementId ?? null) : null;
107
101
  }
108
102
  }
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
103
  /**
116
104
  * Migrate a pre-ZIG-977 persisted ctx (lane-level pendingSubtasks /
117
105
  * collectedResults / delegatedAgreementIds) into the tasks map.
106
+ * ZIG-1057: the deprecated lane aggregates are no longer auto-synced —
107
+ * readers use allPendingSubtasks / allCollectedResults / allDelegatedAgreementIds.
108
+ * This migrator stays so a session persisted before the tasks map still boots.
118
109
  */
119
110
  export function migrateLegacyTaskFields(existing) {
120
111
  if (existing.tasks && typeof existing.tasks === 'object') {
@@ -99,10 +99,19 @@ export function createWakeWriteKeys(laneKey, event) {
99
99
  let ordinal = 0;
100
100
  return {
101
101
  wakeKey: wakeHash === null ? null : `wk_${wakeHash}`,
102
- artifactKey() {
102
+ artifactKey(content) {
103
103
  if (wakeHash === null)
104
104
  return undefined;
105
- return `wk_${wakeHash}_a${ordinal++}`;
105
+ // Content-derived for the same reason messageKey is (ZIG-1045): the
106
+ // ordinal restarts at a0 on every wake, so a redelivered event whose
107
+ // re-think writes different artifacts collided with the first wake's
108
+ // and the backend discarded the new write. Harmless for a breadcrumb,
109
+ // not for a result artifact — which is the whole deliverable of an
110
+ // agent like agent-definer. Callers without content keep the ordinal.
111
+ if (content === undefined)
112
+ return `wk_${wakeHash}_a${ordinal++}`;
113
+ const contentHash = sha(typeof content === 'string' ? content : JSON.stringify(content) ?? '').slice(0, 16);
114
+ return `wk_${wakeHash}_a_${contentHash}`;
106
115
  },
107
116
  taskStateKey(taskId, state) {
108
117
  if (wakeHash === null)
@@ -113,7 +113,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
113
113
  state: 'started',
114
114
  tool: effect.name,
115
115
  ...(argsPreview ? { args: argsPreview } : {}),
116
- }, effect.wakeKeys?.artifactKey());
116
+ }, effect.wakeKeys?.artifactKey({ s: 'started', t: effect.name, a: argsPreview }));
117
117
  try {
118
118
  const lastUser = roster.users.find((u) => u.userId || u.id);
119
119
  // Accumulate token usage from any nested LLM call the tool makes (llmTool),
@@ -150,11 +150,14 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
150
150
  reportUsage,
151
151
  turnScratch: {},
152
152
  });
153
+ // One clip per tool call: this is a full JSON.stringify of the untruncated
154
+ // result, and the artifact body and the idempotency key want the same string.
155
+ const clipped = clipForOperationArtifact(result);
153
156
  await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'completed', {
154
157
  state: 'completed',
155
158
  tool: effect.name,
156
- result: clipForOperationArtifact(result),
157
- }, effect.wakeKeys?.artifactKey());
159
+ result: clipped,
160
+ }, effect.wakeKeys?.artifactKey({ s: 'completed', t: effect.name, r: clipped }));
158
161
  sendTelemetry(deps.telemetryClient, buildToolIngestPayload({
159
162
  sessionId: effect.sessionId,
160
163
  tool: effect.name,
@@ -172,7 +175,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
172
175
  state: 'failed',
173
176
  tool: effect.name,
174
177
  error: errMsg,
175
- }, effect.wakeKeys?.artifactKey());
178
+ }, effect.wakeKeys?.artifactKey({ s: 'failed', t: effect.name, e: errMsg }));
176
179
  sendTelemetry(deps.telemetryClient, buildToolIngestPayload({
177
180
  sessionId: effect.sessionId,
178
181
  tool: effect.name,
@@ -1,5 +1,5 @@
1
1
  import { type Task, type TaskState } from '@ziggs-ai/api-client';
2
- import type { CountTasksFilters } from '@ziggs-ai/api-client';
2
+ import type { CountTasksFilters, CreateTaskData } from '@ziggs-ai/api-client';
3
3
  export declare class TaskService {
4
4
  private creds;
5
5
  constructor(operatorKey: string, agentId: string);
@@ -7,16 +7,7 @@ export declare class TaskService {
7
7
  getActiveTasksForAgent(agentId: string): Promise<Task[]>;
8
8
  getActiveTasksForChat(chatId: string): Promise<Task[]>;
9
9
  getSubtasks(parentTaskId: string): Promise<Task[]>;
10
- spawnUnderAgreement(agreementId: string, taskData: {
11
- description: string;
12
- parentTaskId?: string;
13
- plan?: unknown;
14
- planReviewTiming?: string;
15
- requireMidWorkPlanAck?: boolean;
16
- assigneeId?: string;
17
- inputArtifactIds?: string[];
18
- idempotencyKey?: string;
19
- }): Promise<Task>;
10
+ spawnUnderAgreement(agreementId: string, taskData: Omit<CreateTaskData, 'agreementId'>): Promise<Task>;
20
11
  updateState(taskId: string, state: TaskState, result?: unknown, idempotencyKey?: string): Promise<Task>;
21
12
  updatePlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<Task>;
22
13
  cancel(taskId: string): Promise<Task>;
@@ -1,4 +1,4 @@
1
- import { linkSummary, } from '@ziggs-ai/api-client';
1
+ import { isBroadcastTarget, linkSummary, } from '@ziggs-ai/api-client';
2
2
  import { applyFleetCounterPricing, applyFleetPricingForProtocol, } from '../../../pricing/fleetDefaults.js';
3
3
  /**
4
4
  * ZIG-957 — same summary shape as MCP link mutations / link_* tools.
@@ -22,8 +22,14 @@ function normalizeState(state) {
22
22
  }
23
23
  // ZIG-793: the `plan` tool-schema is underspecified (`plan: {type:'object'}`,
24
24
  // `planReviewTiming: 'string'`), so the LLM emits steps missing stepId/order and an
25
- // out-of-enum planReviewTiming, which the backend DTO rejects. Normalize in-place here
26
- // so every op (propose / subcontract / counter / task-spawn) sends a valid plan.
25
+ // out-of-enum planReviewTiming, which the backend DTO rejects. Normalize in-place
26
+ // before the write.
27
+ //
28
+ // ZIG-1095: task-spawn is the only op left that carries a plan. Propose, counter
29
+ // and subcontract used to run this too — and their plans were then dropped on the
30
+ // floor, since no agreement route has ever accepted one.
31
+ // Returns the clamped review timing rather than only writing it back, so the
32
+ // spawn call gets the narrowed type from the one place that does the clamping.
27
33
  function normalizePlanInPlace(payload) {
28
34
  const plan = payload.plan;
29
35
  if (plan && Array.isArray(plan.steps)) {
@@ -41,16 +47,15 @@ function normalizePlanInPlace(payload) {
41
47
  };
42
48
  });
43
49
  }
44
- if (payload.planReviewTiming !== undefined &&
45
- payload.planReviewTiming !== 'with_proposal' &&
46
- payload.planReviewTiming !== 'before_execution') {
47
- payload.planReviewTiming = 'with_proposal';
48
- }
50
+ if (payload.planReviewTiming === undefined)
51
+ return undefined;
52
+ return payload.planReviewTiming === 'before_execution'
53
+ ? 'before_execution'
54
+ : 'with_proposal';
49
55
  }
50
56
  export async function dispatchProtocolOp(services, payload, chatId, agents = [], actingAgentId = null, wakeKeys = null) {
51
57
  if (!payload?.operation)
52
58
  return null;
53
- normalizePlanInPlace(payload);
54
59
  const { operation } = payload;
55
60
  const { agreementService, taskService } = services;
56
61
  const actorId = actingAgentId?.trim() || null;
@@ -87,9 +92,6 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
87
92
  lifecycle: counterPayload.lifecycle,
88
93
  maxExecutions: counterPayload.maxExecutions,
89
94
  description: counterPayload.description,
90
- plan: counterPayload.plan,
91
- planReviewTiming: counterPayload.planReviewTiming,
92
- requireMidWorkPlanAck: counterPayload.requireMidWorkPlanAck,
93
95
  });
94
96
  }
95
97
  case 'agreement-check-proposal':
@@ -159,11 +161,12 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
159
161
  agreementId: spawnAgreementId,
160
162
  };
161
163
  }
164
+ const planReviewTiming = normalizePlanInPlace(payload);
162
165
  return taskService.spawnUnderAgreement(spawnAgreementId, {
163
166
  description: payload.description,
164
167
  parentTaskId: payload.parentTaskId,
165
168
  plan: payload.plan,
166
- planReviewTiming: payload.planReviewTiming,
169
+ planReviewTiming,
167
170
  requireMidWorkPlanAck: payload.requireMidWorkPlanAck,
168
171
  assigneeId: payload.assigneeId,
169
172
  inputArtifactIds: Array.isArray(payload.inputArtifactIds)
@@ -183,11 +186,9 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
183
186
  });
184
187
  }
185
188
  case 'task-update': {
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
- '';
189
+ // ZIG-1057: `state` only — legacy `status` alias removed. Fleet prompts
190
+ // must emit state (coffee / fruit / ledger / agent-definer).
191
+ const terminalRaw = typeof payload.state === 'string' && payload.state ? payload.state : '';
191
192
  if (!payload.taskId || !terminalRaw)
192
193
  return null;
193
194
  const state = normalizeState(terminalRaw);
@@ -221,7 +222,7 @@ async function proposeAgreement(services, payload, chatId, _agents, actingAgentI
221
222
  throw new Error('agreement-propose requires `description`');
222
223
  if (!payload.proposedTo)
223
224
  throw new Error('agreement-propose requires `proposedTo` (userId or agentId). To create a task under an existing agreement, use `task_create` instead.');
224
- const isBroadcast = payload.proposedTo === 'everyone' || payload.proposedTo === 'org';
225
+ const isBroadcast = isBroadcastTarget(payload.proposedTo);
225
226
  // ZIG-1022: a link proposal is an agreement with engagementKind 'link' —
226
227
  // no chat, no money; the target owner approves. Folds the old request_link.
227
228
  if (payload.engagementKind === 'link') {
@@ -32,10 +32,10 @@ function handlerFor(operation) {
32
32
  return result;
33
33
  };
34
34
  }
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 });
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.' });
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.' });
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', 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, shown as ϟ5.00 in the UI. Convert both ways: a human asking for "ϟ2" or "$2 per task" means 200, not 2. ϟ is the currency symbol, not a cent.' }, 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' }, 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 }, payerId: 'string', price: { type: 'number', description: 'Amount in CENTS — 500 means $5.00, shown as ϟ5.00 in the UI. Convert both ways: a human asking for "ϟ2" or "$2 per task" means 200, not 2. ϟ is the currency symbol, not a cent.' }, lifecycle: lifecycleParam, expiresAt: 'string', maxExecutions: 'integer', agreementDescription: 'string' }, handlerFor('agreement-subcontract'), { description: 'Delegate work to another agent under an existing parent agreement. Requires parentAgreementId.', isAgreementCreation: true });
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. ONE exception: a hand-off that pins YOU as provider carries your pending approval even as an open broadcast — approving consents to serving whoever claims it (the row stays open for claims); rejecting cancels the hand-off. Ignoring it leaves the hand-off unclaimable, so always respond.' });
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), a hand-off (providerPinned: the publisher\'s hired agent works FOR you — you become the customer, and the payer when it is priced), or a link invite (bilateral trust forms immediately). You become the open party and the agreement activates. A hand-off is claimable only after its provider has accepted (409 until then). Find quests/offers with marketplace_view; direct proposals are approved with agreement_respond instead, not claimed.' });
39
39
  export const taskSetResultTool = defineTool('task_set_result', {
40
40
  taskId: { type: 'string', required: true },
41
41
  // ZIG-957: same param name as MCP ziggs_task_set_result / PATCH /tasks/:id/state.
@@ -46,11 +46,25 @@ export const taskSetResultTool = defineTool('task_set_result', {
46
46
  description: 'Outcome payload (summary, status, links, …). Finished work is the task result — never a chat message.',
47
47
  },
48
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.' });
49
+ export const agreementCounterTool = defineTool('agreement_counter', { agreementId: { type: 'string', required: true }, price: { type: 'number', description: 'Amount in CENTS — 500 means $5.00, shown as ϟ5.00 in the UI. Convert both ways: a human asking for "ϟ2" or "$2 per task" means 200, not 2. ϟ is the currency symbol, not a cent.' }, agreementDescription: 'string', expiresAt: 'string', lifecycle: lifecycleParam, maxExecutions: 'integer', description: 'string' }, handlerFor('agreement-counter-proposal'), { description: 'Counter a pending proposal with revised terms instead of approving or rejecting. Check status first with agreement_get.' });
50
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).' });
51
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.' });
52
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.' });
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." });
53
+ // `status` names its four values here because nothing else does: the backend is
54
+ // the only place the vocabulary was written down, and it now rejects anything
55
+ // outside it. Every reader (the chat step badge, the SDK's own plan formatter)
56
+ // already treats an unrecognised status as unknown, so a guessed one was never
57
+ // going to render — this makes the model's choice a closed set instead.
58
+ export const taskUpdatePlanStepTool = defineTool('task_update_plan_step', {
59
+ taskId: { type: 'string', required: true },
60
+ stepId: { type: 'string', required: true },
61
+ status: {
62
+ type: 'string',
63
+ required: true,
64
+ enum: ['pending', 'in_progress', 'completed', 'skipped'],
65
+ },
66
+ result: 'string',
67
+ }, handlerFor('task-update-plan-step'), { description: "Update the status of a single step within a task's plan." });
54
68
  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.' });
55
69
  export const PROTOCOL_TOOLS = [
56
70
  agreementProposeTool, agreementSubcontractTool,
@@ -10,12 +10,23 @@ export function toolFromCapability(cap) {
10
10
  return defineTool(cap.names.sdk, cap.params, async (args, ctx) => {
11
11
  const operatorKey = ctx?.['operatorKey'];
12
12
  const agentId = ctx?.['agentId'];
13
+ // ZIG-1092 — the lane was already threaded to this exact point by
14
+ // ZIG-775's shared executor (`chatId: effect.sessionId` in
15
+ // executeToolWithSideEffects); it just was not used to narrow anything.
16
+ // Reading it here makes the fence brain-agnostic by construction: the
17
+ // Claude brain and the FSM brain both arrive through this adapter.
18
+ const laneId = ctx?.['chatId'] ||
19
+ ctx?.['sessionId'];
13
20
  if (!operatorKey)
14
21
  throw new Error('operatorKey missing from tool context');
15
22
  if (cap.needsAgentId && !agentId)
16
23
  throw new Error('agentId missing from tool context');
17
24
  const env = {
18
- creds: { operatorKey, ...(agentId ? { agentId } : {}) },
25
+ creds: {
26
+ operatorKey,
27
+ ...(agentId ? { agentId } : {}),
28
+ ...(laneId ? { laneId } : {}),
29
+ },
19
30
  // BACKEND_URL / ZIGGS_BACKEND_URL are the SDK runner's overrides; the
20
31
  // shared clients default to HTTP_URL (api-client convention) otherwise.
21
32
  baseUrl: process.env.BACKEND_URL || process.env.ZIGGS_BACKEND_URL || undefined,
@@ -13,8 +13,10 @@ export interface RecordReportInput {
13
13
  taskId: string;
14
14
  report: TaskReport;
15
15
  /**
16
- * Primary artifact scope — required when `deliverable` is provided.
17
- * Pass exactly one of chatId or agreementId.
16
+ * Optional container for the deliverable artifact (ZIG-1037 — it used to be
17
+ * required). The task binding is what makes the deliverable retrievable and
18
+ * readable by the task's parties; a chat or agreement only widens where it also
19
+ * shows up. Pass at most one; when both arrive, agreementId wins.
18
20
  */
19
21
  chatId?: string;
20
22
  agreementId?: string;
@@ -10,21 +10,41 @@ import { ArtifactsClient, updateTaskState, } from '@ziggs-ai/api-client';
10
10
  */
11
11
  export async function recordReport(input) {
12
12
  const { creds, taskId, report, chatId, agreementId, deliverable } = input;
13
- if (deliverable && !chatId && !agreementId) {
14
- throw new Error('recordReport: chatId or agreementId is required when deliverable is provided');
13
+ // ZIG-1037: no container needed. The deliverable is bound to its task, which
14
+ // is what makes it retrievable as `via=task:<taskId>` and readable by the
15
+ // task's parties — a chat or agreement was never what made that work.
16
+ //
17
+ // ZIG-1075: the agreement still wins here, and this is now the ONE place that
18
+ // resolves rather than refuses — deliberately, because the caller is not a
19
+ // model choosing arguments. `recordReport` is handed both ids by the runtime
20
+ // (the task's agreement and the chat it is running in), so there is no
21
+ // mistake to surface and nobody to retry: refusing would fail a finished
22
+ // task's delivery over a pair of ids it did not choose.
23
+ const scope = agreementId ? { agreementId } : chatId ? { chatId } : {};
24
+ // Deliverable FIRST, then close the task.
25
+ //
26
+ // The old order completed the task and then wrote the artifact with the
27
+ // soft-failing `write`, so a wire error left a task marked completed whose
28
+ // deliverable had silently vanished. Recording first means a failure here
29
+ // leaves the task open — the honest state, and one a retry can finish. The
30
+ // idempotency key is derived from the task, so that retry reuses the artifact
31
+ // row instead of recording the deliverable twice.
32
+ let artifactId;
33
+ if (deliverable) {
34
+ const artifactsClient = new ArtifactsClient(creds.operatorKey, creds.agentId);
35
+ ({ artifactId } = await artifactsClient.writeStrict({
36
+ text: deliverable,
37
+ content_type: 'result',
38
+ taskId,
39
+ ...scope,
40
+ visibility: 'chat',
41
+ // Deterministic, not random: one deliverable per task, so a replay of this
42
+ // whole function reproduces the key and dedups server-side.
43
+ idempotencyKey: `task-result:${taskId}`,
44
+ }));
15
45
  }
16
46
  await updateTaskState(taskId, 'completed', { result: { summary: report.summary, status: report.status ?? 'ok', links: report.links ?? [] } }, creds);
17
- if (!deliverable) {
18
- return { taskId };
19
- }
20
- const artifactsClient = new ArtifactsClient(creds.operatorKey, creds.agentId);
21
- await artifactsClient.write({
22
- text: deliverable,
23
- content_type: 'result',
24
- taskId,
25
- chatId,
26
- agreementId,
27
- visibility: 'chat',
28
- });
29
- return { taskId };
47
+ // The artifactId was being discarded even though RecordReportResult declares
48
+ // it — the caller had no way to reference the deliverable it just recorded.
49
+ return artifactId ? { taskId, artifactId } : { taskId };
30
50
  }
package/dist/types.d.ts CHANGED
@@ -188,13 +188,13 @@ export interface Ctx {
188
188
  activeAgreementId: string | null;
189
189
  pendingProposalAgreementId: string | null;
190
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.
191
+ * @deprecated ZIG-1057 — unused mirrors; always `{}` / `[]` after bootstrap.
192
+ * Read via `allPendingSubtasks` / `allCollectedResults` / `allDelegatedAgreementIds`.
195
193
  */
196
194
  pendingSubtasks: Record<string, PendingSubtask>;
195
+ /** @deprecated ZIG-1057 — see `pendingSubtasks`. */
197
196
  collectedResults: CollectedSubtaskResult[];
197
+ /** @deprecated ZIG-1057 — see `pendingSubtasks`. */
198
198
  delegatedAgreementIds: string[];
199
199
  toolResults: ToolResultEntry[];
200
200
  lastOutcome: Outcome;
@@ -385,8 +385,15 @@ export interface RecordedEntry {
385
385
  export interface WakeWriteKeys {
386
386
  /** Stable id of this wake, or null when its trigger has no durable delivery identity (timer/synthetic frames) — null means writes go keyless, as before. */
387
387
  readonly wakeKey: string | null;
388
- /** Key for the next artifact write of this wake. Draws a per-wake ordinal — call exactly once per write, at the call site. */
389
- artifactKey(): string | undefined;
388
+ /**
389
+ * Key for an artifact write of this wake. Pass the artifact's content so the
390
+ * key is derived from it (ZIG-1045 semantics): a wake writing several
391
+ * artifacts gets several keys, and a redelivered wake that re-writes the same
392
+ * content reuses its key instead of colliding on an ordinal slot and being
393
+ * discarded. Omitting content falls back to a per-wake ordinal — call exactly
394
+ * once per write in that case.
395
+ */
396
+ artifactKey(content?: unknown): string | undefined;
390
397
  /** Key for a task state transition. Derived from (taskId, state), so it is stable regardless of tool-call order within the wake. */
391
398
  taskStateKey(taskId: string, state: string): string | undefined;
392
399
  /**
@@ -512,7 +519,19 @@ export interface ServicesConfig {
512
519
  }
513
520
  export interface DefineAgentInput {
514
521
  agentId: string;
522
+ /**
523
+ * Human-facing one-liner: what this agent does, for a buyer reading the store
524
+ * row. NOT the system prompt — a claude-sdk agent puts its prompt in
525
+ * `systemPrompt` (ZIG-1080: every claude-sdk agent used to pass its prompt
526
+ * here, and `deriveAgentRecord` published it straight to the store).
527
+ */
515
528
  description: string;
529
+ /**
530
+ * Claude-brain only: the agent's system prompt (its `## Your role` block).
531
+ * Falls back to `description` when absent, which is what every FSM agent
532
+ * relies on. Never surfaced in the store.
533
+ */
534
+ systemPrompt?: string;
516
535
  specialization?: string;
517
536
  initial: string;
518
537
  states: Record<string, State>;
@@ -534,7 +553,10 @@ export interface AgentConfig {
534
553
  anthropicKey: string | undefined;
535
554
  operatorKey?: string;
536
555
  agentId: string;
556
+ /** Store-facing one-liner. See DefineAgentInput.description. */
537
557
  description: string;
558
+ /** Claude-brain system prompt; falls back to `description`. */
559
+ systemPrompt?: string;
538
560
  specialization?: string;
539
561
  tools: unknown[];
540
562
  model: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,7 +26,7 @@
26
26
  "@anthropic-ai/claude-agent-sdk": "^0.3.204",
27
27
  "@anthropic-ai/sdk": "^0.110.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",
29
- "@ziggs-ai/api-client": "^0.7.1",
29
+ "@ziggs-ai/api-client": "^0.9.0",
30
30
  "ajv": "^8.17.1",
31
31
  "dotenv": "^17.2.3",
32
32
  "openai": "^4.0.0",