@ziggs-ai/agent-sdk 0.31.0 → 0.33.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/dist/Agent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { withAgreementContext } from './runtime/agreementContext.js';
2
- import { agreementLaneId, agreementIdFromLane } from '@ziggs-ai/api-client';
2
+ import { agreementLaneId, agreementIdFromLane, isContextAccessRequest } from '@ziggs-ai/api-client';
3
3
  import { createDeclaredPollRunner } from './platform/polls/declaredPollRunner.js';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -463,8 +463,10 @@ export class Agent {
463
463
  task = await this.taskService.getTask(taskId);
464
464
  }
465
465
  catch (error) {
466
+ // Unreadable is not a hold. The wake filter still drops a frame whose
467
+ // own task cannot be read; a tool that names one gets an error instead.
466
468
  if ([403, 404].includes(error.status ?? 0))
467
- return { blocked: true };
469
+ return { blocked: true, unread: true };
468
470
  throw error;
469
471
  }
470
472
  // A coordinator sees its children's task events too; those do not pause it.
@@ -515,6 +517,29 @@ export class Agent {
515
517
  const ownAgentId = this.options.agentId;
516
518
  if (!ownAgentId)
517
519
  return;
520
+ // A permission decision is a notice about a grant, not authority to run
521
+ // work under the (now fulfilled/cancelled) request. Resume the hire that
522
+ // the server recorded when it validated the request; ordinary read/write
523
+ // gates still recheck that hire. Ignore the duplicate lifecycle echo.
524
+ let resumeAgreementId;
525
+ const reason = typeof event.reason === 'string' ? event.reason : '';
526
+ if (event.kind === 'agreement' && /^(context_request_|approval:|status:cancelled)/.test(reason)) {
527
+ const requestId = String(event.resourceId || event.agreementId || '');
528
+ try {
529
+ const row = await this.agreementService.get(requestId);
530
+ const request = row?.grantRequest;
531
+ if (isContextAccessRequest(request) && typeof request.resumeAgreementId === 'string' && request.resumeAgreementId) {
532
+ if (!reason.startsWith('context_request_'))
533
+ return;
534
+ resumeAgreementId = request.resumeAgreementId;
535
+ }
536
+ }
537
+ catch {
538
+ // A revoked or unavailable agreement must not pin the inbox cursor.
539
+ // Route the original event; normal tool authorization still applies.
540
+ runtimeLog.debug('Agent', 'Access-decision lookup unavailable; using original event');
541
+ }
542
+ }
518
543
  // Map the event to a candidate session key using the configured resolver
519
544
  // on a synthetic normalized-event shape. Default resolver buckets by
520
545
  // chatId; agents using a different sessionResolver (agreementId, taskId,
@@ -543,7 +568,8 @@ export class Agent {
543
568
  if (!this.sessions.has(laneKey)) {
544
569
  this.sessions.set(laneKey, { sessionId, ownAgentId });
545
570
  }
546
- const agreementId = (typeof event.agreementId === 'string' && event.agreementId) ||
571
+ const agreementId = resumeAgreementId ||
572
+ (typeof event.agreementId === 'string' && event.agreementId) ||
547
573
  (event.kind === 'agreement' && typeof event.resourceId === 'string'
548
574
  ? event.resourceId
549
575
  : undefined);
@@ -560,6 +586,14 @@ export class Agent {
560
586
  }
561
587
  /** Wire entry. Normalize → resolve session → enqueue on the right lane. */
562
588
  async handleMessage(text, metadata = {}) {
589
+ // Permission decisions also arrive as a chat notification. The dedicated
590
+ // context_request_* resource event owns continuation; processing this echo
591
+ // would answer twice, once under the permission request instead of the hire.
592
+ const notice = metadata.service;
593
+ if (metadata.entryType === 'notification' &&
594
+ ['proposal_approved', 'proposal_rejected'].includes(notice?.operation ?? '') &&
595
+ isContextAccessRequest(notice?.agreement?.grantRequest))
596
+ return;
563
597
  const messageId = metadata.messageId;
564
598
  if (messageId && this.seenMessages.has(messageId)) {
565
599
  runtimeLog.debug('Agent', `skip duplicate messageId=${messageId}`);
@@ -1086,7 +1120,7 @@ export class Agent {
1086
1120
  release();
1087
1121
  taskRuns.clear();
1088
1122
  };
1089
- const checkpoint = async (effectTaskId) => {
1123
+ const checkpoint = async (namedTaskId) => {
1090
1124
  if (tickAbort.signal.aborted) {
1091
1125
  if (tickAbort.signal.reason?.code === 'TASK_CONTROL')
1092
1126
  heldTask = tickAbort.signal.reason;
@@ -1094,27 +1128,59 @@ export class Agent {
1094
1128
  }
1095
1129
  if (heldTask)
1096
1130
  throw new Error('Task execution held');
1097
- const taskId = effectTaskId ?? marks.scope.taskId ?? wakeTaskId;
1131
+ const wakeId = marks.scope.taskId ?? wakeTaskId;
1132
+ const taskId = namedTaskId ?? wakeId;
1098
1133
  if (!taskId)
1099
1134
  return;
1100
- registerTaskRun(taskId);
1135
+ // Only this wake's own task shares the tick abort. A tool that names
1136
+ // some other task must not be able to cancel the rest of the wake.
1137
+ const ownsWake = namedTaskId == null || namedTaskId === wakeId;
1138
+ if (ownsWake)
1139
+ registerTaskRun(taskId);
1101
1140
  const execution = await readControl(taskId);
1102
1141
  if (heldTask)
1103
1142
  throw new Error('Task execution held');
1104
- if (execution?.blocked) {
1105
- heldTask = { taskId, control: execution.control };
1106
- taskRuns.get(taskId).run.control = execution.control;
1107
- tickAbort.abort(Object.assign(new Error('Task execution held'), { code: 'TASK_CONTROL', taskId, control: execution.control }));
1108
- throw new Error('Task execution held');
1143
+ if (!execution?.blocked)
1144
+ return;
1145
+ if (!ownsWake || execution.unread) {
1146
+ const error = execution.unread
1147
+ ? `Task ${taskId} cannot be read. This call did not run.`
1148
+ : execution.closed
1149
+ ? `Task ${taskId} is cancelled. This call did not run.`
1150
+ : `Task ${taskId} is paused. This call did not run.`;
1151
+ return { error };
1109
1152
  }
1153
+ heldTask = { taskId, control: execution.control };
1154
+ taskRuns.get(taskId).run.control = execution.control;
1155
+ tickAbort.abort(Object.assign(new Error('Task execution held'), { code: 'TASK_CONTROL', taskId, control: execution.control }));
1156
+ throw new Error('Task execution held');
1157
+ };
1158
+ const taskIdNamedBy = (effect) => {
1159
+ if (effect.kind !== 'tool-call' || !effect.args || typeof effect.args !== 'object' || Array.isArray(effect.args))
1160
+ return undefined;
1161
+ const args = effect.args;
1162
+ if (typeof args.taskId === 'string')
1163
+ return args.taskId;
1164
+ const nested = args.args;
1165
+ if ((effect.name === 'tool_read' || effect.name === 'tool_write')
1166
+ && nested && typeof nested === 'object' && !Array.isArray(nested)) {
1167
+ const inner = nested.taskId;
1168
+ if (typeof inner === 'string')
1169
+ return inner;
1170
+ }
1171
+ return undefined;
1110
1172
  };
1111
1173
  const eff = async (effect) => {
1112
1174
  // Record-only telemetry can finish after interruption. Every action and
1113
1175
  // model call checks the current task before starting another operation.
1114
- if (effect.kind !== 'record-event') {
1115
- const args = effect.args;
1116
- const taskId = effect.kind === 'tool-call' && typeof args?.taskId === 'string' ? args.taskId : undefined;
1117
- await checkpoint(taskId);
1176
+ if (effect.kind === 'tool-call') {
1177
+ const refusal = await checkpoint(taskIdNamedBy(effect));
1178
+ // The handler is generic over every effect. This branch is only a tool call.
1179
+ if (refusal)
1180
+ return { ok: false, error: refusal.error };
1181
+ }
1182
+ else if (effect.kind !== 'record-event') {
1183
+ await checkpoint();
1118
1184
  }
1119
1185
  const agreementId = marks.scope.agreementId;
1120
1186
  const sameRoom = 'sessionId' in effect && effect.sessionId === session.sessionId;
@@ -224,7 +224,8 @@ function renderEventLines(event, lines, now) {
224
224
  const decisionActor = (typeof resource.actorId === 'string' && resource.actorId) ||
225
225
  (typeof event.actorId === 'string' && event.actorId) ||
226
226
  null;
227
- const decisionAgreementId = (typeof event.agreementId === 'string' && event.agreementId) ||
227
+ const decisionAgreementId = (resource.kind === 'agreement' && typeof resource.resourceId === 'string' && resource.resourceId) ||
228
+ (typeof event.agreementId === 'string' && event.agreementId) ||
228
229
  (typeof resource.agreementId === 'string' && resource.agreementId) ||
229
230
  (resource.kind === 'agreement' &&
230
231
  typeof resource.resourceId === 'string' &&
@@ -238,11 +239,11 @@ function renderEventLines(event, lines, now) {
238
239
  surface: 'sdk',
239
240
  });
240
241
  if (reason === 'context_request_fulfilled' || reason === 'context_request_rejected') {
241
- lines.push('Work signal: the owner answered your artifact access request. ' +
242
+ lines.push('Work signal: the owner answered your context access request. ' +
242
243
  (decision ? `${decision.sentence} ` : '') +
243
- 'Read the agreement and this conversation to recover the pending work. ' +
244
+ 'Read the permission request and this conversation to recover the pending work. The wake agreementId is the ongoing hire to act under; the resourceId identifies the permission decision. ' +
244
245
  (reason === 'context_request_fulfilled'
245
- ? 'Open the original artifact using the approved grant, verify the read succeeds, and deliver the requested answer to its original recipient. '
246
+ ? 'Read the original resource using the approved grant (context_read for a workspace), verify the read succeeds, and deliver the requested answer to its original recipient. '
246
247
  : 'Access was rejected: report the unresolved dependency to the original requester. ') +
247
248
  'This is an access decision, not a new hire; do not create a new task or repeat your introduction.');
248
249
  }
@@ -286,18 +287,6 @@ function renderEventLines(event, lines, now) {
286
287
  lines.push(`Agreement: ${agreementId}`);
287
288
  }
288
289
  }
289
- // The other side of the same policy: a message this agent sent to a
290
- // published agent landed and was NOT rung through. Nothing is wrong and
291
- // nothing is lost, so this must not read as a refusal — but without it the
292
- // sender waits on a reply that is never coming and its rational next move
293
- // is to send again.
294
- if (reason === 'contact_policy_withheld') {
295
- lines.push('Your message was delivered and is readable, but the agent you wrote ' +
296
- 'to is not woken for it: its listing is claim-first, so it starts ' +
297
- 'work once you claim the listing. Do not resend — claim the listing ' +
298
- '(ziggs_marketplace_view to find it, then ziggs_agreement_claim), or ' +
299
- 'wait if somebody else is claiming on your behalf.');
300
- }
301
290
  }
302
291
  if (event?.agreementContext) {
303
292
  lines.push(`Service agreement context (server read): ${JSON.stringify(event.agreementContext)}`);
@@ -27,6 +27,7 @@ Your prose and final summary are private host notes, not messages to the people
27
27
  - Closing a task closes the TASK, and only the task. It stores your result where the next agent reads it, queues a wake for whoever the platform addresses, and on an agreement that bills per completed task it meters that one execution. It does not end the hire — a standing agreement stays active for the next task — it does not prove anyone has read you, and it does not prove you were paid. The reply tells you which of those actually happened: report that and nothing beyond it. A completion whose reply you never saw is recovered by READING the task back, never by completing it a second time; your retry carries the same write key, so a redelivered completion no-ops instead of doubling the result, the wake and the charge.
28
28
  - Enumerate open work with \`task_list\` (use assignedToMe for your own active tasks).
29
29
  - Chat is where you talk to whoever you are working with, and where a human reads what you delivered — sending it to them there is delivery, not duplication. It is not where another AGENT collects a deliverable: that one reads the task result, so a chat message never stands in for closing the task. Deliver into the working conversation the hire already has; do not repeat it into every room you can reach.
30
+ - Use any room you can write in, or open one (newChat for a separate subject). The server refuses what you are not allowed to do, and the refusal says why.
30
31
  - When a store listing of yours is claimed (\`resource_changed\` with reason \`proposal_approved\`), call \`chat_open\` with the buyer (actorId / Sender) AND the claimed agreementId. That returns the hire's room, which the server usually made at claim, or opens one when there is none. Greet them only when the agreement accepts messages, then serve the agreement terms. A task-only agreement needs no chat or greeting: wait for assigned tasks and deliver results through tasks. Do not \`chat_send\` to an \`agrn-*\` session id — open a real chat first.
31
32
  - When a task you hold carries a deadline or a reminder rule, they are part of the job, not decoration. Never remind the same person sooner than the stated interval or more times than the budget; use \`wake_at\` to come back rather than waiting in place, and re-arm it each time you wake. When the budget is spent, stop asking and say what you have. At the deadline, post what you got and where you are stuck in the room where the question was asked, close the task, and stop — whether or not you got the answer. Nothing stops you from breaking this, which is exactly why it is on you: over-reminding is a breach of the task, not diligence, and every reminder costs a wake.
32
33
  - Hand off by completing your task — the platform notifies the next agent.
@@ -70,11 +70,11 @@ export const contextSnapshotTool = defineTool('context_snapshot', {
70
70
  }, { description: 'Read the current wake’s exact task or agreement, governing constraints, dependencies and artifact inventory. No chat is required for task work. For chat wakes, the NEWEST maxMessages of history (chronological order), agreements, participants, and a bounded inventory of readable room artifacts. Artifact bodies are not included: read relevant notes with context_read type=artifacts via=chat:<chatId>, following pagination, before concluding information is missing. Zero direct artifact grants does not mean no readable room notes. Direct grants absent from that room inventory are grantedArtifacts (id and name only); read a body with context_read type=artifacts via=artifact:<id>.' });
71
71
  export const chatSendTool = defineTool('chat_send', {
72
72
  taskId: { type: 'string', description: 'Work task; the backend resolves its governing agreement.' },
73
- conversationAgreementId: { type: 'string', description: 'Explicit service work requested from the recipient, separate from your own run authority.' },
73
+ conversationAgreementId: { type: 'string', description: 'The agreement this message is under. In the room you were woken in, leave it out unless you mean a different agreement than this wake. When a send is refused with WORK_SELECTION_REQUIRED, name one of its choices.' },
74
74
  underAgreementId: { type: 'string', description: 'Your run authority, defaulting to the current run in every destination room.' },
75
75
  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, such as one you just opened with chat_open. When chatId is set it wins over `to`.' },
76
76
  to: { type: 'string', description: 'User or agent id of someone you already have a conversation with. Sends in that pair room. Naming the person whose message woke you sends in the room that woke you instead, addressed to them. Does not open contact — first contact still needs chat_open. Ignored when chatId is set (chatId wins; `to` is used as receiverId if receiverId is omitted).' },
77
- receiverId: { type: 'string', description: 'Explicit recipient user or agent id, or a participant reference from this destination room. Omit it when the room has one other participant: they are the recipient. With several, Jev chooses when confident; otherwise this is room context with no specific wake. The wake sender and last writer are never defaults. Pass human for an intentional broadcast to people.' },
77
+ receiverId: { type: 'string', description: 'Name someone only when you mean them. Leave it out to write back in this room: one other participant is the recipient, and with several Jev may select one. Otherwise the message stays in the room with no specific wake. To name a masked party, pass its rpb_ ref as it came. Pass human to address the people in the room.' },
78
78
  text: { type: 'string', required: true },
79
79
  idempotencyKey: { type: 'string', description: 'Reuse for retry-safe resend of the same logical message.' },
80
80
  }, async (args, ctx) => {
@@ -8,7 +8,8 @@
8
8
  * The tools stay registered on the ToolManager — the FSM brain and the
9
9
  * dispatcher both call them — they just stop riding every wake as schemas.
10
10
  */
11
- import { EVERYDAY_SDK_TOOLS, annotationForSdkTool } from '@ziggs-ai/api-client';
11
+ import { EVERYDAY_SDK_TOOLS, annotationForSdkTool, backendToolPick } from '@ziggs-ai/api-client';
12
+ import { credsFromToolContext } from '../tools/toolCreds.js';
12
13
  import { defineTool, toolName } from '../tools/defineTool.js';
13
14
  /**
14
15
  * Platform tools written by hand, not as a shared capability. Their lane is
@@ -132,6 +133,7 @@ async function callCatalogTarget(name, payload, ctx, execute) {
132
133
  function catalogTools(present, execute) {
133
134
  const list = () => [...present.values()].map(rowFor).sort((a, b) => a.name.localeCompare(b.name));
134
135
  const toolsTool = defineTool('tools', {
136
+ need: { type: 'string', description: 'Describe what you need to do. Suggests a tool and its schema; falls back to the catalog when unsure.' },
135
137
  search: {
136
138
  type: 'string',
137
139
  description: 'Filter by name or the one-line description. Omit to list every tool.',
@@ -141,7 +143,7 @@ function catalogTools(present, execute) {
141
143
  items: { type: 'string' },
142
144
  description: 'Tool names whose full schema you want before calling them.',
143
145
  },
144
- }, async (args) => {
146
+ }, async (args, ctx) => {
145
147
  const describe = Array.isArray(args.describe) ? args.describe.map(String) : [];
146
148
  if (describe.length > 0) {
147
149
  return describe.map((name) => {
@@ -152,10 +154,22 @@ function catalogTools(present, execute) {
152
154
  return { ...rowFor(tool), schema: tool.schema.function };
153
155
  });
154
156
  }
157
+ const need = typeof args.need === 'string' ? args.need.trim() : '';
158
+ if (need) {
159
+ const rows = list();
160
+ try {
161
+ const pick = await backendToolPick(credsFromToolContext(ctx, 'tools'))(need, rows.map(({ name, line }) => ({ name, line })));
162
+ const tool = pick?.how === 'pick' && pick.tool ? present.get(pick.tool) : undefined;
163
+ if (tool)
164
+ return { suggestion: { ...rowFor(tool), schema: tool.schema.function }, confidence: pick?.confidence };
165
+ }
166
+ catch { /* Missing credentials or picker failure still leaves every tool available. */ }
167
+ return { tools: rows, note: 'No confident suggestion. Choose from the catalog; no action was taken.' };
168
+ }
155
169
  const q = typeof args.search === 'string' ? args.search.toLowerCase() : '';
156
170
  return list().filter((row) => !q || row.name.includes(q) || row.line.toLowerCase().includes(q));
157
171
  }, {
158
- description: 'Every Ziggs tool that exists: name plus one line. A handful are already loaded by name; the rest are called through tool_read or tool_write. Pass describe with tool names to get their full schemas. Nothing is hidden.',
172
+ description: 'Every Ziggs tool that exists: name plus one line. A handful are already loaded by name; the rest are called through tool_read or tool_write. Pass need for a plain-language tool suggestion with its schema, or describe with exact names. Suggestions never execute tools. Nothing is hidden.',
159
173
  });
160
174
  const dispatch = (via) => async (args, ctx) => {
161
175
  const name = typeof args.tool === 'string' ? args.tool : '';
@@ -395,14 +395,17 @@ export async function createMcpAgent(options) {
395
395
  : 'the external service';
396
396
  // Open a connection request for this user; returns its id so the caller can
397
397
  // deep-link the user straight to it on the Connections page.
398
- const triggerConnectionRequest = async (userId) => {
398
+ // The server holds the invoking user to the people this wake may ask, so
399
+ // the request carries the same lane as the call that missed: in the hire's
400
+ // room that user is its customer.
401
+ const triggerConnectionRequest = async (userId, laneId) => {
399
402
  if (!transportConfig.serverUrl)
400
403
  return null;
401
404
  const operatorKey = transportConfig.operatorKey || process.env.ZIGGS_OPERATOR_KEY;
402
405
  if (!operatorKey)
403
406
  return null;
404
407
  try {
405
- const body = await new ConnectionsClient(operatorKey, agentId, backendBase).requestMcpConnection({
408
+ const body = await new ConnectionsClient(operatorKey, agentId, backendBase, laneId).requestMcpConnection({
406
409
  serverUrl: transportConfig.serverUrl,
407
410
  tools: tools.map(t => t.name),
408
411
  onBehalfOfUserId: userId,
@@ -460,7 +463,7 @@ export async function createMcpAgent(options) {
460
463
  if (lastErr instanceof ZiggsGatewayError &&
461
464
  lastErr.code === 'NO_USER_CONNECTION' &&
462
465
  transportConfig.serverUrl) {
463
- const requestId = await triggerConnectionRequest(userId).catch(() => null);
466
+ const requestId = await triggerConnectionRequest(userId, laneId).catch(() => null);
464
467
  const link = connectionsSettingsAppUrl(appUrl, requestId);
465
468
  return {
466
469
  content: [
@@ -89,7 +89,7 @@ export declare class PromptBuilder {
89
89
  _renderAgreements(agreements: AnyObj[]): string;
90
90
  _renderRecipients(users: AnyObj[], otherAgents: AnyObj[], agreements: AnyObj[], myAgentId: string | null | undefined): string;
91
91
  _renderDecisionSchemaFromActions(actions: Record<string, Action>, senderMeta: SenderMeta, ctxObj: ServerContext, omitMessage?: boolean): string;
92
- _buildActionSchema(name: string, replyTo: string, omitMessage?: boolean): string;
92
+ _buildActionSchema(name: string, omitMessage?: boolean): string;
93
93
  _getSenderMeta(event: RawEvent | AnyObj | null, ctxObj: ServerContext): SenderMeta;
94
94
  _getMyAgentId(ctxObj: ServerContext): string | null;
95
95
  }
@@ -322,9 +322,10 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
322
322
  }
323
323
  }
324
324
  // Chat keeps the full directory — including opaque `rpb_*` / `psn_*` refs
325
- // the persona layer may surface. Echo those as chat_send receiverId; the
326
- // backend resolves them in-room. Transfer (and any account-id action) must
327
- // never list opaque refs — lookup / wake / pay against them 400s.
325
+ // the persona layer may surface. To name a masked party, pass its `rpb_`
326
+ // ref as it came; the backend resolves it in-room. Transfer (and any
327
+ // account-id action) must never list opaque refs — lookup / wake / pay
328
+ // against them 400s.
328
329
  const chatIds = new Set([...userIds, ...agentIds]);
329
330
  const transferIds = new Set([...userIds, ...agentIds].filter((id) => !isOpaquePresentationRef(id)));
330
331
  const lines = ['<recipients>'];
@@ -381,8 +382,8 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
381
382
  const lines = ['<decision_schema>'];
382
383
  lines.push('Return ONE JSON object. ALWAYS include "thought" as the FIRST field — briefly explain your reasoning before choosing an action.');
383
384
  lines.push('Set "action" to exactly one of the action names below.');
385
+ lines.push('Name "receiverId" only when you mean a specific recipient. Leave it out to write back in this room; an answer without one is sent without one.');
384
386
  lines.push('Message text is rendered as markdown (CommonMark + GFM). You MAY use **bold**, *italic*, `code`, fenced code blocks, lists, links, and tables when they improve clarity — plain text is also fine.\n');
385
- const replyTo = '<receiverId from <recipients>>';
386
387
  const pendingProposals = (ctxObj.agreements || []).filter((a) => a.proposal?.status === 'pending' && (a.proposedToIsYou || isBroadcastTarget(a.parties?.proposedTo?.principal)));
387
388
  if (pendingProposals.length > 0) {
388
389
  lines.push(`PRIORITY: You have ${pendingProposals.length} pending proposal(s) directed at you. Respond BEFORE other actions.`);
@@ -402,7 +403,7 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
402
403
  lines.push(`Response: {"thought": "...", "action": "${name}", "args": {...}}`);
403
404
  }
404
405
  else {
405
- lines.push(this._buildActionSchema(name, replyTo, omitMessage));
406
+ lines.push(this._buildActionSchema(name, omitMessage));
406
407
  }
407
408
  if (actionDef.prompt?.examples?.length) {
408
409
  lines.push('Examples:');
@@ -417,22 +418,14 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
417
418
  lines.push('</decision_schema>');
418
419
  return lines.join('\n');
419
420
  }
420
- _buildActionSchema(name, replyTo, omitMessage) {
421
+ _buildActionSchema(name, omitMessage) {
421
422
  if (name === 'wait') {
422
423
  return `Response: {"thought": "...", "action": "${name}"}`;
423
424
  }
424
- // Never pre-fill a non-routable id (the literal 'system' or 'service'
425
- // buckets) as the default receiverId. Fall back to an explicit placeholder
426
- // so the model picks a real recipient from <recipients>.
427
- const isSystemReplyTo = !replyTo ||
428
- replyTo === 'system' ||
429
- replyTo === 'service' ||
430
- replyTo.startsWith('<');
431
- const renderedReplyTo = isSystemReplyTo ? '<receiverId from <recipients>>' : replyTo;
432
425
  if (omitMessage) {
433
- return `Response: {"thought": "...", "action": "${name}", "receiverId": "${renderedReplyTo}"}`;
426
+ return `Response: {"thought": "...", "action": "${name}"}`;
434
427
  }
435
- return `Response: {"thought": "...", "action": "${name}", "receiverId": "${renderedReplyTo}", "message": "..."}`;
428
+ return `Response: {"thought": "...", "action": "${name}", "message": "..."}`;
436
429
  }
437
430
  _getSenderMeta(event, ctxObj) {
438
431
  const ev = event;
@@ -450,7 +450,7 @@ export async function runTurn(input) {
450
450
  repromptedForDecision = true;
451
451
  runtimeLog.warn('runTurn', `unexecutable decision — reprompting once: action "${chosenAction}" missing required fields`);
452
452
  messages.push({ role: 'assistant', content: llmResponse.content ?? '' });
453
- messages.push({ role: 'user', content: `Your last output was not a valid action: action "${chosenAction}" was missing required fields (for a message this means both "receiverId" and "message"). Respond with exactly one action from the schema.` });
453
+ messages.push({ role: 'user', content: `Your last output was not a valid action: action "${chosenAction}" was missing required fields (for a message this means "message"). Respond with exactly one action from the schema.` });
454
454
  continue;
455
455
  }
456
456
  const events = (Array.isArray(result) ? result : [result]);
@@ -666,7 +666,7 @@ async function executeDecision(decision, actionDef, eff, sessionId, memory, inco
666
666
  if (decision.messages && Array.isArray(decision.messages) && decision.messages.length > 0) {
667
667
  return await executeSendMessages(decision, eff, sessionId);
668
668
  }
669
- if (decision.message && decision.receiverId) {
669
+ if (decision.message) {
670
670
  return await executeSendMessage(decision, eff, sessionId);
671
671
  }
672
672
  // Fallback: LLM may place message/receiverId inside args due to format ambiguity
@@ -707,7 +707,8 @@ function isReplySlotTaken(err) {
707
707
  return err?.status === 409;
708
708
  }
709
709
  async function executeSendMessage(decision, eff, sessionId) {
710
- // Callers (executeDecision) guarantee message + receiverId, and sessionId is
710
+ // Callers guarantee message text. receiverId is set only when the model
711
+ // named someone; an answer without one is sent without one. sessionId is
711
712
  // always ctx.identity.sessionId. A send failure throws to the turn loop,
712
713
  // which converts it into the 'send-failed' error outcome — no silent nulls,
713
714
  // and message_sent is only emitted after the send effect resolved.
@@ -716,16 +717,22 @@ async function executeSendMessage(decision, eff, sessionId) {
716
717
  // to the current session otherwise.
717
718
  const targetSessionId = decision.chatId || sessionId;
718
719
  const safeMessage = truncateMsg(decision.message);
720
+ const receiverId = decision.receiverId || undefined;
719
721
  try {
720
- await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: decision.receiverId, text: safeMessage });
722
+ await eff({
723
+ kind: 'send-message',
724
+ sessionId: targetSessionId,
725
+ ...(receiverId ? { receiverId } : {}),
726
+ text: safeMessage,
727
+ });
721
728
  }
722
729
  catch (err) {
723
730
  if (!isReplySlotTaken(err))
724
731
  throw err;
725
- runtimeLog.warn('runTurn', `reply already persisted for this wake (idempotency 409) — a replay or second host answered first; dropping duplicate to ${decision.receiverId}`);
726
- return [{ type: 'message_duplicate_skipped', receiverId: decision.receiverId, chatId: targetSessionId, senderId: 'system' }];
732
+ runtimeLog.warn('runTurn', `reply already persisted for this wake (idempotency 409) — a replay or second host answered first; dropping duplicate${receiverId ? ` to ${receiverId}` : ''}`);
733
+ return [{ type: 'message_duplicate_skipped', ...(receiverId ? { receiverId } : {}), chatId: targetSessionId, senderId: 'system' }];
727
734
  }
728
- const events = [{ type: 'message_sent', receiverId: decision.receiverId, chatId: targetSessionId, message: safeMessage, senderId: 'system' }];
735
+ const events = [{ type: 'message_sent', ...(receiverId ? { receiverId } : {}), chatId: targetSessionId, message: safeMessage, senderId: 'system' }];
729
736
  if (targetSessionId !== sessionId)
730
737
  events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
731
738
  return events;
@@ -744,15 +751,20 @@ async function executeSendMessages(decision, eff, sessionId) {
744
751
  const failures = [];
745
752
  let attempted = 0;
746
753
  for (const msg of decision.messages ?? []) {
747
- if (!msg.receiverId || !msg.message) {
748
- failures.push({ receiverId: msg.receiverId || '<missing>', error: 'missing receiverId or message' });
754
+ if (!msg.message) {
755
+ failures.push({ receiverId: msg.receiverId || '<missing>', error: 'missing message' });
749
756
  continue;
750
757
  }
751
758
  const targetSessionId = msg.chatId || sessionId;
752
759
  const safeMessage = truncateMsg(msg.message);
753
760
  attempted++;
754
761
  try {
755
- await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: msg.receiverId, text: safeMessage });
762
+ await eff({
763
+ kind: 'send-message',
764
+ sessionId: targetSessionId,
765
+ ...(msg.receiverId ? { receiverId: msg.receiverId } : {}),
766
+ text: safeMessage,
767
+ });
756
768
  }
757
769
  catch (err) {
758
770
  if (isReplySlotTaken(err)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.31.0",
3
+ "version": "0.33.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.284",
27
27
  "@anthropic-ai/sdk": "^0.110.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",
29
- "@ziggs-ai/api-client": "0.30.0",
29
+ "@ziggs-ai/api-client": "0.32.0",
30
30
  "ajv": "^8.17.1",
31
31
  "dotenv": "^17.2.3",
32
32
  "openai": "^4.0.0",