@ziggs-ai/agent-sdk 0.41.2 → 0.42.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.
package/CHANGELOG.md CHANGED
@@ -32,6 +32,8 @@
32
32
 
33
33
  ## Unreleased
34
34
 
35
+ - Expose the shared `task_delegate` capability on the hosted agent surface for recursive task assignments and approved artifact returns.
36
+
35
37
  ## 0.41.2
36
38
 
37
39
  ## 0.41.1
package/dist/Agent.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type WorkExecutionContext } from '@ziggs-ai/api-client';
1
2
  import type { PollBinding, PollKind } from './platform/polls/PollKind.js';
2
3
  export { workScopeFromWake } from './runtime/wakeEffects.js';
3
4
  import type { AgentConfig, Ctx, Workflow } from './types.js';
@@ -212,6 +213,7 @@ export interface WakeRecord {
212
213
  agentId: string;
213
214
  sessionId: string;
214
215
  laneKey: string;
216
+ executionContext?: WorkExecutionContext;
215
217
  /** The event frame that woke the agent. */
216
218
  event: unknown;
217
219
  /** FSM context carried into the tick — undefined on a cold lane. */
@@ -298,6 +300,12 @@ export declare class Agent {
298
300
  private carryExecutionState;
299
301
  /** Runtime control is a task fact, never model-interpreted message text. */
300
302
  private taskExecutionControl;
303
+ /**
304
+ * Control on an assignment the inbox will not run yet. No wake follows, so a
305
+ * resume is acknowledged here too once no run of the task is live; the wake
306
+ * path that would otherwise answer it never comes while the delivery is blocked.
307
+ */
308
+ private observeBlockedTaskControl;
301
309
  private acknowledgeExecutionControl;
302
310
  /**
303
311
  * Handler for a resource change (artifact created, task state transition,
@@ -411,6 +419,7 @@ export declare class Agent {
411
419
  */
412
420
  private canAskForAccess;
413
421
  private _processEvent;
422
+ private _processEventInContext;
414
423
  /**
415
424
  * The checkout a wake needs, opened before the brain runs. Null when this
416
425
  * wake needs none; `'refused'` when the task was failed here by name.
@@ -433,6 +442,7 @@ export declare class Agent {
433
442
  * Borrowing that path would make a dev tool able to disturb a live one.
434
443
  */
435
444
  replayWake(record: WakeRecord): Promise<TickOutput>;
445
+ private replayWakeInContext;
436
446
  /** Arms the lane's liveness timer iff stateName is a parked state declaring timeoutMs. */
437
447
  private _armIfTimedParked;
438
448
  private _resolveOwnAgentId;
package/dist/Agent.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateWorkExecution, withWorkExecution, currentWorkExecution } from '@ziggs-ai/api-client';
1
2
  import { SERVER_EVENTS } from '@ziggs-ai/contracts';
2
3
  import { withAgreementContext } from './runtime/agreementContext.js';
3
4
  import { agreementLaneId, agreementIdFromLane, isContextAccessRequest } from '@ziggs-ai/api-client';
@@ -521,8 +522,10 @@ export class Agent {
521
522
  if (!this.taskService)
522
523
  return null;
523
524
  let task;
525
+ // Host control observation is separate from task work. Only execution state
526
+ // leaves this method; a stopped work context cannot observe its own stop.
524
527
  try {
525
- task = await this.taskService.getTask(taskId);
528
+ task = await withWorkExecution(undefined, () => this.taskService.getTask(taskId));
526
529
  }
527
530
  catch (error) {
528
531
  // Unreadable is not a hold. The wake filter still drops a frame whose
@@ -536,6 +539,17 @@ export class Agent {
536
539
  return null;
537
540
  return { blocked: task.state === 'cancelled' || (task.state === 'active' && task.control?.action === 'pause'), closed: task.state === 'cancelled', control: task.control };
538
541
  }
542
+ /**
543
+ * Control on an assignment the inbox will not run yet. No wake follows, so a
544
+ * resume is acknowledged here too once no run of the task is live; the wake
545
+ * path that would otherwise answer it never comes while the delivery is blocked.
546
+ */
547
+ async observeBlockedTaskControl(taskId) {
548
+ const execution = await this.taskExecutionControl(taskId);
549
+ const idleResume = execution?.control?.action === 'resume' && !this.activeTaskRuns.get(taskId)?.size;
550
+ if (execution?.blocked || idleResume)
551
+ await this.acknowledgeExecutionControl(taskId, execution?.control);
552
+ }
539
553
  async acknowledgeExecutionControl(taskId, control) {
540
554
  if (control?.status !== 'requested')
541
555
  return;
@@ -626,9 +640,10 @@ export class Agent {
626
640
  }
627
641
  if (!sessionId)
628
642
  return;
629
- const laneKey = laneKeyFor(ownAgentId, sessionId);
643
+ const executionContext = event.executionContext;
644
+ const laneKey = laneKeyFor(ownAgentId, sessionId, executionContext?.contextId);
630
645
  if (!this.sessions.has(laneKey)) {
631
- this.sessions.set(laneKey, { sessionId, ownAgentId });
646
+ this.sessions.set(laneKey, { sessionId, ownAgentId, executionContext });
632
647
  }
633
648
  const agreementId = resumeAgreementId ||
634
649
  (typeof event.agreementId === 'string' && event.agreementId) ||
@@ -656,7 +671,7 @@ export class Agent {
656
671
  ['proposal_approved', 'proposal_rejected'].includes(notice?.operation ?? '') &&
657
672
  isContextAccessRequest(notice?.agreement?.grantRequest))
658
673
  return;
659
- const messageId = metadata.messageId;
674
+ const messageId = (metadata.responsibilityId ?? metadata.messageId);
660
675
  if (messageId && this.seenMessages.has(messageId)) {
661
676
  runtimeLog.debug('Agent', `skip duplicate messageId=${messageId}`);
662
677
  return;
@@ -688,9 +703,10 @@ export class Agent {
688
703
  runtimeLog.warn('Agent', `session resolver returned null ownAgentId=${ownAgentId} chatId=${normalized.chatId ?? 'null'}`);
689
704
  return;
690
705
  }
691
- const laneKey = laneKeyFor(ownAgentId, sessionId);
706
+ const executionContext = metadata.executionContext;
707
+ const laneKey = laneKeyFor(ownAgentId, sessionId, executionContext?.contextId);
692
708
  if (!this.sessions.has(laneKey)) {
693
- this.sessions.set(laneKey, { sessionId, ownAgentId });
709
+ this.sessions.set(laneKey, { sessionId, ownAgentId, executionContext });
694
710
  }
695
711
  // The inbox loop owns ack (act-before-ack, delivery law); the agent only
696
712
  // routes the event onto its lane. No live-path watermark ack — there is no
@@ -745,6 +761,9 @@ export class Agent {
745
761
  }), isTelemetryIngestEnabled(this.options.telemetryIngest));
746
762
  }
747
763
  memoryForWake(session, event) {
764
+ const execution = currentWorkExecution();
765
+ if (execution)
766
+ return partitionMemoryStore(this.memory, `execution:${execution.contextId}`);
748
767
  const fromEvent = hireIdsFromEvent(event);
749
768
  const wakeAgreementIds = fromEvent.length ? fromEvent : uniqueHireIds([session.actingHireId]);
750
769
  const section = memorySectionForWake({
@@ -939,7 +958,9 @@ export class Agent {
939
958
  // say so in a sentence, and muting it would be a worse silence.
940
959
  let assigned;
941
960
  try {
942
- assigned = await this.taskService.getActiveTasksForAgent(agentId);
961
+ // A host check across this agent's own plate, not a read inside the
962
+ // work: it runs outside the selected execution.
963
+ assigned = await withWorkExecution(undefined, () => this.taskService.getActiveTasksForAgent(agentId));
943
964
  }
944
965
  catch (err) {
945
966
  runtimeLog.warn(this.options.name || 'agent', `needs check could not read assigned work: ${err?.message ?? err}`);
@@ -1026,9 +1047,14 @@ export class Agent {
1026
1047
  return tools.some((t) => toolName(t) === 'connection_request');
1027
1048
  }
1028
1049
  async _processEvent(event, laneKey) {
1050
+ return withWorkExecution(this.sessions.get(laneKey)?.executionContext, () => this._processEventInContext(event, laneKey));
1051
+ }
1052
+ async _processEventInContext(event, laneKey) {
1029
1053
  const session = this.sessions.get(laneKey);
1030
1054
  if (!session?.sessionId)
1031
1055
  return;
1056
+ if (session.executionContext)
1057
+ await validateWorkExecution(session.executionContext, this.options.operatorKey, resolveBackendUrl());
1032
1058
  // Presentation may call our sender an rpb-* room binding or the person we
1033
1059
  // stand in for. Only an exact (chat, messageId) we successfully sent proves
1034
1060
  // an echo; another message from the same binding still gets a full wake.
@@ -1144,6 +1170,7 @@ export class Agent {
1144
1170
  if (this.options.wakeTap) {
1145
1171
  try {
1146
1172
  this.options.wakeTap({
1173
+ executionContext: session.executionContext,
1147
1174
  agentId: identity.agentId,
1148
1175
  sessionId: identity.sessionId,
1149
1176
  laneKey,
@@ -1439,7 +1466,7 @@ export class Agent {
1439
1466
  needs,
1440
1467
  taskId,
1441
1468
  getTask: (id) => this.taskService.getTask(id),
1442
- listConnections: () => new ConnectionsClient(operatorKey, agentId, undefined, laneId).listForHolder(),
1469
+ listConnections: () => new ConnectionsClient(operatorKey, agentId, undefined).listForHolder(),
1443
1470
  door: { backendUrl: resolveBackendUrl(), operatorKey, agentId, laneId },
1444
1471
  failTask: async (id, reason) => {
1445
1472
  await this.taskService.updateState(id, 'failed', { summary: reason, status: 'failed' });
@@ -1481,8 +1508,13 @@ export class Agent {
1481
1508
  * Borrowing that path would make a dev tool able to disturb a live one.
1482
1509
  */
1483
1510
  async replayWake(record) {
1511
+ return withWorkExecution(record.executionContext, () => this.replayWakeInContext(record));
1512
+ }
1513
+ async replayWakeInContext(record) {
1484
1514
  if (!record?.sessionId)
1485
1515
  throw new Error('replayWake: record.sessionId is required');
1516
+ if (record.executionContext)
1517
+ await validateWorkExecution(record.executionContext, this.options.operatorKey, resolveBackendUrl());
1486
1518
  const identity = {
1487
1519
  agentId: record.agentId || this.options.agentId,
1488
1520
  sessionId: record.sessionId,
@@ -1593,6 +1625,7 @@ export class Agent {
1593
1625
  onResourceEvent: (event) => {
1594
1626
  return this.handleResourceEvent(event);
1595
1627
  },
1628
+ onTaskControlCheck: (taskId) => this.observeBlockedTaskControl(taskId),
1596
1629
  // work delivered by the inbox's task channel, not by a chat
1597
1630
  // row — this is what reaches a task under a chat-less agreement or one
1598
1631
  // the agent assigned to itself.
@@ -1602,6 +1635,7 @@ export class Agent {
1602
1635
  }
1603
1636
  catch (err) {
1604
1637
  runtimeLog.warn(this.options.name || 'agent', `inbox task ${task.taskId} could not be woken: ${err.message}`);
1638
+ throw err;
1605
1639
  }
1606
1640
  },
1607
1641
  // exact-match triage in code — miss acks with 0 tokens.
@@ -1631,6 +1665,7 @@ export class Agent {
1631
1665
  firesAt: armed.firesAt,
1632
1666
  reason,
1633
1667
  ...(scope ? { scope } : {}),
1668
+ executionContext: currentWorkExecution(),
1634
1669
  });
1635
1670
  return { ...armed, durable: this.wakeLedger.durable };
1636
1671
  }
@@ -1653,12 +1688,12 @@ export class Agent {
1653
1688
  return;
1654
1689
  }
1655
1690
  for (const wake of pending) {
1656
- if (wake.laneKey !== laneKeyFor(agentId, wake.sessionId))
1691
+ if (wake.laneKey !== laneKeyFor(agentId, wake.sessionId, wake.executionContext?.contextId))
1657
1692
  continue;
1658
1693
  if (this.stateTimers.peek(wake.laneKey))
1659
1694
  continue;
1660
1695
  if (!this.sessions.has(wake.laneKey)) {
1661
- this.sessions.set(wake.laneKey, { sessionId: wake.sessionId, ownAgentId: agentId });
1696
+ this.sessions.set(wake.laneKey, { sessionId: wake.sessionId, ownAgentId: agentId, executionContext: wake.executionContext });
1662
1697
  }
1663
1698
  try {
1664
1699
  this.stateTimers.armWakeAt(wake.laneKey, wake.firesAt, wake.reason, wake.scope);
@@ -1704,7 +1739,7 @@ export class Agent {
1704
1739
  const sessionId = laneSessionIdForTask(full);
1705
1740
  const laneKey = laneKeyFor(agentId, sessionId);
1706
1741
  if (!this.sessions.has(laneKey)) {
1707
- this.sessions.set(laneKey, { sessionId, ownAgentId: agentId });
1742
+ this.sessions.set(laneKey, { sessionId, ownAgentId: agentId, executionContext: currentWorkExecution() });
1708
1743
  }
1709
1744
  await this.eventQueue.enqueue({
1710
1745
  type: 'task_result',
@@ -1,5 +1,5 @@
1
1
  import { type ToolDefinition } from '../tools/defineTool.js';
2
- import { cancelTaskTool, listTasksTool } from '../platform/tools/tier2/taskTools.js';
2
+ import { cancelTaskTool, listTasksTool, delegateTaskTool } from '../platform/tools/tier2/taskTools.js';
3
3
  import type { EffectHandler, DeliveryWriteKeys } from '../types.js';
4
4
  import type { MemoryStore } from '../memory/MemoryStore.js';
5
5
  export interface ClaudeSdkToolContext extends Record<string, unknown> {
@@ -25,5 +25,5 @@ export interface ClaudeSdkToolContext extends Record<string, unknown> {
25
25
  }
26
26
  export declare const contextSnapshotTool: ToolDefinition;
27
27
  export declare const chatSendTool: ToolDefinition;
28
- export { listTasksTool, cancelTaskTool };
28
+ export { listTasksTool, cancelTaskTool, delegateTaskTool };
29
29
  export declare const CLAUDE_SDK_TOOLS: ToolDefinition[];
@@ -5,7 +5,7 @@ import { credsFromToolContext } from '../tools/toolCreds.js';
5
5
  import { sendChatMessage, presentSendResult } from '@ziggs-ai/api-client';
6
6
  import { defineTool } from '../tools/defineTool.js';
7
7
  import { listGrantsTool } from '../platform/tools/tier2/grantsTools.js';
8
- import { cancelTaskTool, listTasksTool } from '../platform/tools/tier2/taskTools.js';
8
+ import { cancelTaskTool, listTasksTool, delegateTaskTool } from '../platform/tools/tier2/taskTools.js';
9
9
  import { CHAT_TOOLS } from '../platform/tools/tier2/chatTools.js';
10
10
  // default message-history cap. An uncapped snapshot returns the whole
11
11
  // chat — in a delegation chat that accumulates notification rows, every wake's
@@ -168,13 +168,14 @@ export const chatSendTool = defineTool('chat_send', {
168
168
  // task_list IS here: MCP delegates have ziggs_task_list; the hosted
169
169
  // Claude surface previously had no way to enumerate its own open tasks (reads
170
170
  // were prompt-injected only). Same GET /tasks capability as the MCP tool.
171
- export { listTasksTool, cancelTaskTool };
171
+ export { listTasksTool, cancelTaskTool, delegateTaskTool };
172
172
  export const CLAUDE_SDK_TOOLS = [
173
173
  contextSnapshotTool,
174
174
  chatSendTool,
175
175
  listGrantsTool,
176
176
  listTasksTool,
177
177
  cancelTaskTool,
178
+ delegateTaskTool,
178
179
  // store-claim providers must open the buyer chat from the default
179
180
  // Claude surface (chat_open was tier-2 / email-agent opt-in only).
180
181
  ...CHAT_TOOLS,
@@ -1,4 +1,4 @@
1
- import { buildOperatorHeaders, getBackendUrl } from '@ziggs-ai/api-client';
1
+ import { buildOperatorHeaders, getBackendUrl, withoutExecution } from '@ziggs-ai/api-client';
2
2
  import { getBatchEvents } from '../context/batch.js';
3
3
  import { runtimeLog } from '../shared/runtimeLog.js';
4
4
  /** Longer than Jev's own 4s budget on the server, shorter than a turn is worth waiting for. */
@@ -52,7 +52,7 @@ export function backendWakeGate(creds) {
52
52
  const res = await fetch(`${getBackendUrl()}/internal/wake-gate`, {
53
53
  method: 'POST',
54
54
  headers: {
55
- ...buildOperatorHeaders(creds.operatorKey, creds.agentId),
55
+ ...withoutExecution(buildOperatorHeaders(creds.operatorKey, creds.agentId)),
56
56
  'Content-Type': 'application/json',
57
57
  },
58
58
  body: JSON.stringify(req),
@@ -9,6 +9,7 @@ export interface GitDoorTarget {
9
9
  operatorKey: string;
10
10
  agentId: string;
11
11
  laneId?: string;
12
+ executionContextId?: string;
12
13
  connectionId: string;
13
14
  grantId: string;
14
15
  taskId: string;
@@ -1,4 +1,3 @@
1
- import { laneHeader } from '@ziggs-ai/api-client';
2
1
  import { runtimeLog } from '../shared/runtimeLog.js';
3
2
  /** Headers git sends that the door needs to see; anything else stays behind. */
4
3
  const GIT_REQUEST_HEADERS = ['content-type', 'content-encoding', 'accept', 'git-protocol'];
@@ -23,8 +22,8 @@ export function doorHeaders(door, gitHeaders) {
23
22
  'x-grant-id': door.grantId,
24
23
  'x-ziggs-task-id': door.taskId,
25
24
  };
26
- for (const [key, value] of Object.entries(laneHeader(door.laneId)))
27
- out[key.toLowerCase()] = value;
25
+ if (door.executionContextId)
26
+ out['x-ziggs-execution'] = door.executionContextId;
28
27
  for (const [k, v] of Object.entries(gitHeaders)) {
29
28
  if (GIT_REQUEST_HEADERS.includes(k.toLowerCase()))
30
29
  out[k.toLowerCase()] = v;
@@ -1,3 +1,4 @@
1
+ import { assistantRequestHeaders } from '@ziggs-ai/api-client';
1
2
  import { createHash, randomBytes } from 'node:crypto';
2
3
  import { CheckoutBoxClient, doorHeaders } from './boxClient.js';
3
4
  import { checkoutBranchForTask, checkoutTargetFromTask } from './intake.js';
@@ -69,6 +70,7 @@ export async function openCheckout(deps) {
69
70
  continue;
70
71
  const candidate = {
71
72
  ...deps.door,
73
+ executionContextId: assistantRequestHeaders()['X-Ziggs-Execution'],
72
74
  connectionId: group.connectionId,
73
75
  grantId,
74
76
  taskId,
@@ -1,4 +1,4 @@
1
- import { resolveWebAppOrigin, connectionsSettingsAppUrl, laneHeader } from '@ziggs-ai/api-client';
1
+ import { resolveWebAppOrigin, connectionsSettingsAppUrl, assistantRequestHeaders, currentWorkExecution } from '@ziggs-ai/api-client';
2
2
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
3
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
4
4
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
@@ -24,13 +24,19 @@ export class ZiggsGatewayError extends Error {
24
24
  this.status = status;
25
25
  }
26
26
  }
27
+ /** Open gateway sessions kept per selected execution before the oldest closes. */
28
+ const MAX_WORK_SESSIONS = 16;
27
29
  /**
28
30
  * Wrap fetch so structured gateway denials (401/403 with a `code` body)
29
31
  * surface as ZiggsGatewayError instead of a generic transport failure.
30
32
  */
31
33
  export function wrapGatewayFetch(baseFetch = fetch) {
32
34
  return (async (input, init) => {
33
- const response = await baseFetch(input, init);
35
+ const headers = new Headers(init?.headers);
36
+ headers.delete('X-Ziggs-Execution');
37
+ for (const [key, value] of Object.entries(assistantRequestHeaders()))
38
+ headers.set(key, value);
39
+ const response = await baseFetch(input, { ...init, headers });
34
40
  if (response.status === 401 || response.status === 403) {
35
41
  const body = (await response
36
42
  .clone()
@@ -71,7 +77,7 @@ export function resolveZiggsGatewayTarget(config, defaults = {}) {
71
77
  // its lane, so the same account and the same grant got two different
72
78
  // answers depending on which door the agent used. Built by the shared
73
79
  // helper, because a copy of this header going missing is invisible.
74
- ...laneHeader(defaults.laneId),
80
+ ...assistantRequestHeaders(),
75
81
  },
76
82
  };
77
83
  }
@@ -97,7 +103,7 @@ export function resolvePerUserGatewayTarget(config, agentId, userId, onBehalfOfA
97
103
  // uses this agreement to resolve the delegation root's user.
98
104
  ...(onBehalfOfAgreementId ? { 'x-on-behalf-of-agreement': onBehalfOfAgreementId } : {}),
99
105
  // The lane, for the same reason as the path-id door above.
100
- ...laneHeader(laneId),
106
+ ...assistantRequestHeaders(),
101
107
  },
102
108
  };
103
109
  }
@@ -343,7 +349,7 @@ export async function createMcpAgent(options) {
343
349
  // grants this session may spend, so sessions must not be shared across
344
350
  // any of the three. The headers are fixed when the transport is built,
345
351
  // so a session reused across lanes would carry the first lane's fence.
346
- const cacheKey = `${userId}|${onBehalfOfAgreementId ?? ''}|${laneId ?? ''}`;
352
+ const cacheKey = `${userId}|${onBehalfOfAgreementId ?? ''}|${assistantRequestHeaders()['X-Ziggs-Execution'] ?? ''}`;
347
353
  let p = clients.get(cacheKey);
348
354
  if (!p) {
349
355
  p = (async () => {
@@ -387,7 +393,7 @@ export async function createMcpAgent(options) {
387
393
  if (!operatorKey)
388
394
  return null;
389
395
  try {
390
- const body = await new ConnectionsClient(operatorKey, agentId, backendBase, laneId).requestMcpConnection({
396
+ const body = await new ConnectionsClient(operatorKey, agentId, backendBase).requestMcpConnection({
391
397
  serverUrl: transportConfig.serverUrl,
392
398
  tools: tools.map(t => t.name),
393
399
  onBehalfOfUserId: userId,
@@ -477,7 +483,31 @@ export async function createMcpAgent(options) {
477
483
  try {
478
484
  await client.connect(transport);
479
485
  const rawTools = capTools((await client.listTools()).tools, agentOptions.agentId, maxTools, extraTools.length, allowedTools);
480
- const caller = (name, args) => client.callTool({ name, arguments: args });
486
+ // A brokered gateway session is bound to the work it was opened in, so a
487
+ // call made inside a selected execution opens (and keeps) a session of its
488
+ // own; the boot session serves calls made outside any execution.
489
+ const workClients = new Map();
490
+ const clientFor = () => {
491
+ const work = transportConfig.type === 'ziggs' ? currentWorkExecution()?.contextId : undefined;
492
+ if (!work)
493
+ return Promise.resolve(client);
494
+ let pending = workClients.get(work);
495
+ if (!pending) {
496
+ if (workClients.size >= MAX_WORK_SESSIONS) {
497
+ const [oldest, closing] = workClients.entries().next().value;
498
+ workClients.delete(oldest);
499
+ void closing.then(c => c.close()).catch(() => { });
500
+ }
501
+ const scoped = new Client({ name: 'ziggs-mcp-agent', version: '1.0.0' });
502
+ pending = scoped
503
+ .connect(buildTransport(transportConfig, { agentId: agentOptions.agentId }))
504
+ .then(() => scoped);
505
+ pending.catch(() => workClients.delete(work));
506
+ workClients.set(work, pending);
507
+ }
508
+ return pending;
509
+ };
510
+ const caller = async (name, args) => (await clientFor()).callTool({ name, arguments: args });
481
511
  const mcpTools = mapMcpTools(caller, rawTools);
482
512
  const resolvedWorkflow = workflow ?? buildDefaultWorkflow(agentOptions.description ?? '', rawTools);
483
513
  const agent = createAgent({
@@ -487,7 +517,9 @@ export async function createMcpAgent(options) {
487
517
  });
488
518
  return {
489
519
  agent,
490
- closeMcp: () => client.close(),
520
+ closeMcp: async () => {
521
+ await Promise.all([client, ...workClients.values()].map(c => Promise.resolve(c).then(open => open.close()).catch(() => { })));
522
+ },
491
523
  toolNames: rawTools.map((t) => t.name),
492
524
  };
493
525
  }
@@ -1,3 +1,4 @@
1
+ import type { WorkExecutionContext } from '@ziggs-ai/api-client';
1
2
  import { type MemoryStore } from '../memory/MemoryStore.js';
2
3
  import type { WorkScope } from '../runtime/wakeEffects.js';
3
4
  /** One check-back an agent asked for, as it is kept across a restart. */
@@ -7,6 +8,7 @@ export interface SavedWake {
7
8
  firesAt: number;
8
9
  reason: string | null;
9
10
  scope?: WorkScope;
11
+ executionContext?: WorkExecutionContext;
10
12
  }
11
13
  /**
12
14
  * Where an agent's own check-backs are kept, so a restart does not lose them.
@@ -60,6 +60,7 @@ export class WakeLedger {
60
60
  firesAt: saved.firesAt,
61
61
  reason: saved.reason ?? null,
62
62
  ...(saved.scope ? { scope: saved.scope } : {}),
63
+ ...(saved.executionContext ? { executionContext: saved.executionContext } : {}),
63
64
  });
64
65
  }
65
66
  return out;
@@ -28,7 +28,7 @@ async function buildSnapshot(deps, chatId, agentId, opts = {}) {
28
28
  // Read as this wake: the session is the lane, the way its tool calls are
29
29
  // sent. Without it a room filed in another workspace — a stranger's room
30
30
  // with a listed agent — reads as missing and the wake is skipped.
31
- const reader = deps.contextReadClient.withLane?.(chatId) ?? deps.contextReadClient;
31
+ const reader = deps.contextReadClient;
32
32
  const snap = await reader.snapshot(chatId, {
33
33
  maxMessages: opts.limit,
34
34
  ...opts.workScope,
@@ -29,7 +29,9 @@ export declare class AgreementService {
29
29
  */
30
30
  publishOffer(payload: PublishOfferPayload): Promise<Agreement>;
31
31
  delegate(payload: DelegateAgreementData): Promise<Agreement>;
32
- respond(agreementId: string, action: 'approve' | 'reject'): Promise<Agreement>;
32
+ respond(agreementId: string, action: 'approve' | 'reject', opts?: {
33
+ mandateAgreementId?: string;
34
+ }): Promise<Agreement>;
33
35
  /**
34
36
  * claim any open broadcast (request, standing offer, hand-off,
35
37
  * link invite). Routing (offer vs agreement claim endpoint) is decided in
@@ -44,8 +44,8 @@ export class AgreementService {
44
44
  delegate(payload) {
45
45
  return delegateAgreement(payload, this.creds);
46
46
  }
47
- respond(agreementId, action) {
48
- return respondToAgreement(agreementId, action, this.creds);
47
+ respond(agreementId, action, opts = {}) {
48
+ return respondToAgreement(agreementId, action, this.creds, opts);
49
49
  }
50
50
  /**
51
51
  * claim any open broadcast (request, standing offer, hand-off,
@@ -32,6 +32,8 @@ export interface InboxLoopDeps {
32
32
  * public request burns a full Claude turn per published supplier.
33
33
  */
34
34
  onRequest?: (request: InboxRequestRef) => Promise<void>;
35
+ /** Host-only control observation for blocked task assignments; never a model wake. */
36
+ onTaskControlCheck?: (taskId: string) => Promise<void>;
35
37
  label?: string;
36
38
  }
37
39
  export interface InboxPassResult {
@@ -52,6 +54,13 @@ export interface InboxPassResult {
52
54
  * watermark was deliberately held, so the same window arrives again.
53
55
  */
54
56
  failures: number;
57
+ /**
58
+ * Assigned work this host was not allowed to run: a delivery it cannot open
59
+ * or prepare, a task or decision whose execution was refused. Skipped, not
60
+ * failed: the server keeps it pending, stops ringing for it, and offers it
61
+ * again once it can run, so it neither holds the watermark nor slows the loop.
62
+ */
63
+ blocked: number;
55
64
  }
56
65
  /**
57
66
  * A capability probe waits on a `probe_*` chat reply, not inbox ack. Act
@@ -1,3 +1,4 @@
1
+ import { deliveryHandlingId, withWorkExecution } from '@ziggs-ai/api-client';
1
2
  import { ContextReadClient, InboxClient, planInboxAck, planPartialInboxAck, } from '@ziggs-ai/api-client';
2
3
  import { runtimeLog } from '../shared/runtimeLog.js';
3
4
  import { isRateLimited } from '@ziggs-ai/api-client';
@@ -64,9 +65,9 @@ function proposalIsMine(proposal, ownAgentId) {
64
65
  async function deliverMessagesForChat(deps, news, refs, reader, attempt, handledIds) {
65
66
  const pending = new Map(refs.map(ref => [ref.resourceId, ref]));
66
67
  for (const id of pending.keys()) {
67
- if (deps.seen.has(id)) {
68
+ if (deps.seen.has(deliveryHandlingId(pending.get(id)))) {
69
+ handledIds.add(deliveryHandlingId(pending.get(id)));
68
70
  pending.delete(id);
69
- handledIds.add(id);
70
71
  }
71
72
  }
72
73
  if (!pending.size)
@@ -89,6 +90,9 @@ async function deliverMessagesForChat(deps, news, refs, reader, attempt, handled
89
90
  throw err;
90
91
  // This read explicitly establishes that these deliveries are inaccessible.
91
92
  // The server also excludes out-of-reach rows from its acknowledgment guard.
93
+ // A context refusal (including changed audience) must be retried, never acknowledged as read.
94
+ if (refs.some(ref => ref.executionContext))
95
+ throw err;
92
96
  for (const id of pending.keys())
93
97
  handledIds.add(id);
94
98
  return delivered;
@@ -104,11 +108,12 @@ async function deliverMessagesForChat(deps, news, refs, reader, attempt, handled
104
108
  // Preserve the message's addressee. Assignment separately authorizes
105
109
  // this carrier to handle it, including a person's stand-in.
106
110
  assigneeId: deps.ownAgentId,
111
+ ...(ref.responsibilityId ? { responsibilityId: ref.responsibilityId, executionContext: ref.executionContext } : {}),
107
112
  ...(ref.agreementId ? { agreementId: ref.agreementId } : {}),
108
113
  }));
109
114
  if (handled) {
110
115
  delivered++;
111
- handledIds.add(id);
116
+ handledIds.add(deliveryHandlingId(ref));
112
117
  }
113
118
  pending.delete(id);
114
119
  }
@@ -191,18 +196,52 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
191
196
  // and the one the window actually bites — releases as soon as it is done.
192
197
  // Several, and the oldest is worked last, so the release waits for it: no
193
198
  // worse than acking at the end, which is what this replaced.
199
+ // Blocked work is the server's to retry, not a handover that failed here:
200
+ // it stays pending without ringing, so holding the mark or backing off for
201
+ // it would only delay the work that can run.
202
+ const blocked = envelope.deliveries.filter(d => mine(d) && (d.executionBlocked || d.outOfReach)).length
203
+ + (envelope.tasksAwaitingMe ?? []).filter(t => t.executionBlocked).length
204
+ + envelope.proposalsAwaitingMe.filter(p => p.assignedAgentId === deps.ownAgentId && p.executionBlocked).length;
205
+ if (blocked) {
206
+ log(label, `${blocked} assignment(s) cannot run yet (no access or execution refused); left pending for the server to offer again`);
207
+ }
208
+ if (deps.onTaskControlCheck) {
209
+ const blockedTasks = new Set([
210
+ ...envelope.deliveries.filter(d => mine(d) && d.kind === 'task-state' && d.executionBlocked && !d.outOfReach).map(d => d.resourceId),
211
+ ...(envelope.tasksAwaitingMe ?? []).filter(t => t.executionBlocked).map(t => t.taskId),
212
+ ]);
213
+ for (const taskId of blockedTasks) {
214
+ try {
215
+ await deps.onTaskControlCheck(taskId);
216
+ }
217
+ catch (error) {
218
+ warn(label, `task control observation failed for ${taskId}: ${error.message}`);
219
+ }
220
+ }
221
+ }
194
222
  const handledSoFar = new Set();
195
- let lastAckedTo = null;
223
+ const acknowledged = new Set();
224
+ // A handled id the server would not settle (it lost access mid-wake) stays
225
+ // pending there and comes back once it can run; this pass is not redone.
226
+ const noteRefused = (result) => {
227
+ const refused = result ? result.refusedDeliveryIds : undefined;
228
+ if (refused?.length) {
229
+ warn(label, `ack left ${refused.length} handled assignment(s) pending: ${refused.map(r => `${r.id} (${r.reason})`).join(', ')}`);
230
+ }
231
+ };
196
232
  const ackWhatIsFinished = async () => {
197
233
  const step = planPartialInboxAck(envelope, handledSoFar, {
198
234
  ownAgentId: deps.ownAgentId,
199
235
  failures,
200
236
  });
201
- if (!step || step.ackTo === lastAckedTo)
237
+ if (!step)
238
+ return;
239
+ step.handledDeliveryIds = step.handledDeliveryIds.filter(id => !acknowledged.has(id));
240
+ if (!step.handledDeliveryIds.length)
202
241
  return;
203
242
  try {
204
- await inbox.ack(step.ackTo, { handledResourceIds: step.handledResourceIds });
205
- lastAckedTo = step.ackTo;
243
+ noteRefused(await inbox.ack(step.ackTo, { handledDeliveryIds: step.handledDeliveryIds }));
244
+ step.handledDeliveryIds.forEach(id => acknowledged.add(id));
206
245
  log(label, `acked up to ${step.ackTo} (mid-pass)`);
207
246
  }
208
247
  catch (err) {
@@ -214,22 +253,25 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
214
253
  };
215
254
  const assignedByChat = new Map();
216
255
  for (const d of envelope.deliveries) {
217
- if (d.kind !== 'message' || !d.chatId || !mine(d) || d.outOfReach)
256
+ if (d.kind !== 'message' || !d.chatId || !mine(d) || d.outOfReach || d.executionBlocked)
218
257
  continue;
219
- const rows = assignedByChat.get(d.chatId) ?? [];
258
+ const groupId = d.executionContext?.contextId ?? d.chatId;
259
+ const rows = assignedByChat.get(groupId) ?? [];
220
260
  rows.push(d);
221
- assignedByChat.set(d.chatId, rows);
261
+ assignedByChat.set(groupId, rows);
222
262
  }
223
263
  const chats = [...assignedByChat].map(([chatId, refs]) => ({
224
264
  chatId, count: refs.length, latestAt: refs.map(ref => ref.ts).sort().at(-1),
225
265
  }));
226
- for (const news of chatsInWakeOrder(chats)) {
266
+ for (const group of chatsInWakeOrder(chats)) {
267
+ const refs = assignedByChat.get(group.chatId);
268
+ const news = { ...group, chatId: refs[0].chatId };
227
269
  // Read a room's mail as that room's wake would: with the room as the lane.
228
270
  // Without it the read is judged in the workspace the key signed in to, and
229
271
  // a room another company opened with a listed agent is filed in theirs, so
230
272
  // every stranger's message came back 403 and was acked unread.
231
- const roomReader = new ContextReadClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl, news.chatId);
232
- const n = await deliverMessagesForChat(deps, news, assignedByChat.get(news.chatId), roomReader, attempt, handledSoFar);
273
+ const roomReader = new ContextReadClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
274
+ const n = await withWorkExecution(refs[0]?.executionContext, () => deliverMessagesForChat(deps, news, refs, roomReader, attempt, handledSoFar));
233
275
  messagesDelivered += n;
234
276
  await ackWhatIsFinished();
235
277
  }
@@ -243,13 +285,13 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
243
285
  for (const d of envelope.deliveries) {
244
286
  if (d.kind === 'message' || d.kind === 'request')
245
287
  continue;
246
- if (!mine(d))
288
+ if (!mine(d) || d.executionBlocked || d.outOfReach)
247
289
  continue;
248
- const key = `delivery:${d.kind}:${d.resourceId}:${d.ts}`;
290
+ const key = d.responsibilityId ?? `delivery:${d.kind}:${d.resourceId}:${d.ts}`;
249
291
  // Handled on an earlier pass — it still settles this instant, exactly as
250
292
  // it does in the end-of-pass list.
251
293
  if (deps.seen.has(key)) {
252
- handledSoFar.add(d.resourceId);
294
+ handledSoFar.add(deliveryHandlingId(d));
253
295
  continue;
254
296
  }
255
297
  deps.seen.mark(key);
@@ -257,6 +299,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
257
299
  kind: d.kind,
258
300
  ts: d.ts,
259
301
  resourceId: d.resourceId,
302
+ ...(d.responsibilityId ? { responsibilityId: d.responsibilityId, executionContext: d.executionContext } : {}),
260
303
  ...(d.chatId ? { chatId: d.chatId } : {}),
261
304
  ...(d.agreementId ? { agreementId: d.agreementId } : {}),
262
305
  ...(d.taskId ? { taskId: d.taskId } : {}),
@@ -268,7 +311,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
268
311
  }), () => deps.seen.unmark(key));
269
312
  if (handled) {
270
313
  resourceEvents++;
271
- handledSoFar.add(d.resourceId);
314
+ handledSoFar.add(deliveryHandlingId(d));
272
315
  }
273
316
  }
274
317
  // An older row here may have been holding the mark back behind the chats.
@@ -278,12 +321,14 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
278
321
  // taskId+updatedAt — a genuinely touched task wakes again, an untouched one
279
322
  // doesn't storm the lane every long-poll.
280
323
  for (const task of envelope.tasksAwaitingMe ?? []) {
281
- const key = `task:${task.taskId}:${task.updatedAt ?? ''}`;
324
+ if (task.executionBlocked)
325
+ continue;
326
+ const key = `task:${task.taskId}:${task.updatedAt ?? ''}:${task.executionContext?.contextId ?? ''}`;
282
327
  if (deps.seen.has(key))
283
328
  continue;
284
329
  deps.seen.mark(key);
285
330
  if (deps.onTask) {
286
- const handled = await attempt(`task ${task.taskId}`, () => deps.onTask(task), () => deps.seen.unmark(key));
331
+ const handled = await attempt(`task ${task.taskId}`, () => withWorkExecution(task.executionContext, () => deps.onTask(task)), () => deps.seen.unmark(key));
287
332
  if (handled)
288
333
  tasksDelivered++;
289
334
  }
@@ -296,7 +341,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
296
341
  for (const request of envelope.openRequestsAwaitingMe ?? []) {
297
342
  const key = `request:${request.agreementId}:${request.ts}`;
298
343
  if (deps.seen.has(key)) {
299
- handledSoFar.add(request.agreementId);
344
+ handledSoFar.add(request.responsibilityId);
300
345
  continue;
301
346
  }
302
347
  deps.seen.mark(key);
@@ -304,25 +349,27 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
304
349
  const handled = await attempt(`request ${request.agreementId}`, () => deps.onRequest(request), () => deps.seen.unmark(key));
305
350
  if (handled) {
306
351
  requestsTriaged++;
307
- handledSoFar.add(request.agreementId);
352
+ handledSoFar.add(request.responsibilityId);
308
353
  }
309
354
  }
310
355
  else {
311
356
  // No handler → still count as handled (ack) so an old host cannot wedge
312
357
  // forever on request mail it does not understand.
313
358
  requestsTriaged++;
314
- handledSoFar.add(request.agreementId);
359
+ handledSoFar.add(request.responsibilityId);
315
360
  }
316
361
  }
317
362
  // Pending decisions are standing state, not deliveries: they keep appearing
318
363
  // until answered, so dedup them the same way tasks are deduped.
319
364
  for (const proposal of envelope.proposalsAwaitingMe) {
365
+ if (proposal.executionBlocked)
366
+ continue;
320
367
  // A proposal may be visible because this agent shares the accountable
321
368
  // principal's mailbox. Only the role-designated side actor wakes; ambient
322
369
  // sibling proposals remain available as context without creating a lane.
323
370
  if (!proposalIsMine(proposal, deps.ownAgentId))
324
371
  continue;
325
- const key = `proposal:${proposal.agreementId}`;
372
+ const key = `proposal:${proposal.agreementId}${proposal.executionContext ? ':' + proposal.executionContext.contextId : ''}`;
326
373
  if (deps.seen.has(key))
327
374
  continue;
328
375
  deps.seen.mark(key);
@@ -333,6 +380,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
333
380
  agreementId: proposal.agreementId,
334
381
  change: 'created',
335
382
  reason: 'proposal_pending',
383
+ ...(proposal.executionContext ? { executionContext: proposal.executionContext } : {}),
336
384
  }), () => deps.seen.unmark(key));
337
385
  if (handled)
338
386
  resourceEvents++;
@@ -344,14 +392,16 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
344
392
  // Repeated failures never turn unread mail into an acknowledged delivery.
345
393
  // One function, shared with the MCP inbox tool — do not restate the list
346
394
  // or the allow-gate here.
347
- let acked = false;
395
+ let acked = acknowledged.size > 0;
348
396
  const ack = planInboxAck(envelope, {
349
397
  ownAgentId: deps.ownAgentId,
350
398
  failures,
351
399
  forceAck: false,
352
400
  });
353
- if (ack.allowed && ack.handledResourceIds.every(id => handledSoFar.has(id))) {
354
- await inbox.ack(envelope.ackTo, { handledResourceIds: ack.handledResourceIds });
401
+ if (ack.allowed && ack.handledDeliveryIds.every(id => handledSoFar.has(id))) {
402
+ const remaining = ack.handledDeliveryIds.filter(id => !acknowledged.has(id));
403
+ if (remaining.length || !acked)
404
+ noteRefused(await inbox.ack(envelope.ackTo, { handledDeliveryIds: remaining }));
355
405
  acked = true;
356
406
  log(label, `acked up to ${envelope.ackTo}`);
357
407
  }
@@ -368,6 +418,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
368
418
  acked,
369
419
  truncated: envelope.deliveriesCapped,
370
420
  failures,
421
+ blocked,
371
422
  };
372
423
  }
373
424
  /**
@@ -380,7 +431,7 @@ const ERROR_BACKOFF_BASE_MS = 1_000;
380
431
  const ERROR_BACKOFF_MAX_MS = 30_000;
381
432
  /** Count held windows so retries can back off without discarding mail. */
382
433
  export function heldPassAfter(prev, pass) {
383
- return !pass.acked && (pass.failures > 0 || pass.truncated) ? prev + 1 : 0;
434
+ return pass.failures > 0 || (!pass.acked && pass.truncated) ? prev + 1 : 0;
384
435
  }
385
436
  function sleep(ms, signal) {
386
437
  return new Promise((resolve) => {
@@ -8,4 +8,4 @@
8
8
  * `a:b` while the queue dispatches `a::b` simply never wakes anything, with
9
9
  * nothing in the logs to say so.
10
10
  */
11
- export declare function laneKeyFor(agentId: string, sessionId: string): string;
11
+ export declare function laneKeyFor(agentId: string, sessionId: string, executionContextId?: string): string;
@@ -1,3 +1,4 @@
1
+ import { currentWorkExecution } from '@ziggs-ai/api-client';
1
2
  /**
2
3
  * The lane key for one agent's one conversation.
3
4
  *
@@ -8,6 +9,6 @@
8
9
  * `a:b` while the queue dispatches `a::b` simply never wakes anything, with
9
10
  * nothing in the logs to say so.
10
11
  */
11
- export function laneKeyFor(agentId, sessionId) {
12
- return `${agentId}:${sessionId}`;
12
+ export function laneKeyFor(agentId, sessionId, executionContextId) {
13
+ return `${agentId}:${executionContextId ?? currentWorkExecution()?.contextId ?? sessionId}`;
13
14
  }
@@ -24,7 +24,7 @@ export { PAYMENT_TOOLS, paymentBalanceTool } from './tier2/paymentTools.js';
24
24
  export { CONNECTION_TOOLS, connectionProxyTool, connectionListGrantsTool, requestConnectionTool, } from './tier2/connectionTools.js';
25
25
  export { CONTEXT_TOOLS, contextReadTool, accessRequestTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
26
26
  export { GRANTS_TOOLS, listGrantsTool } from './tier2/grantsTools.js';
27
- export { TASK_TOOLS, listTasksTool, cancelTaskTool } from './tier2/taskTools.js';
27
+ export { TASK_TOOLS, listTasksTool, cancelTaskTool, delegateTaskTool } from './tier2/taskTools.js';
28
28
  export { PLAN_TOOLS, taskReplacePlanTool } from './tier2/planTools.js';
29
29
  export { LINK_TOOLS, linkProposeTool, linkListTool, } from './tier2/linkTools.js';
30
30
  export { INTRODUCTION_TOOLS, introductionMintTool, introductionRedeemTool, introductionListTool, introductionRevokeTool, } from './tier2/introductionTools.js';
@@ -28,7 +28,7 @@ export { CONTEXT_TOOLS, contextReadTool, accessRequestTool, contextDelegateTool,
28
28
  // one grant lister across every rail (retires context_discover +
29
29
  // payment_list_grants). Always-available on the hosted agent — see CLAUDE_SDK_TOOLS.
30
30
  export { GRANTS_TOOLS, listGrantsTool } from './tier2/grantsTools.js';
31
- export { TASK_TOOLS, listTasksTool, cancelTaskTool } from './tier2/taskTools.js';
31
+ export { TASK_TOOLS, listTasksTool, cancelTaskTool, delegateTaskTool } from './tier2/taskTools.js';
32
32
  export { PLAN_TOOLS, taskReplacePlanTool } from './tier2/planTools.js';
33
33
  // links (agreements with engagementKind 'link') — cross-org reach.
34
34
  ///1022: the rail is two tools; propose/claim/revoke ride the
@@ -72,7 +72,9 @@ cachedAgreements) {
72
72
  case 'agreement-respond':
73
73
  if (!payload.agreementId || !payload.action)
74
74
  return null;
75
- return agreementService.respond(payload.agreementId, payload.action);
75
+ return agreementService.respond(payload.agreementId, payload.action, typeof payload.mandateAgreementId === 'string' && payload.mandateAgreementId
76
+ ? { mandateAgreementId: payload.mandateAgreementId }
77
+ : {});
76
78
  case 'agreement-claim': {
77
79
  // the one claim verb — request, standing offer, or link invite.
78
80
  if (!payload.agreementId)
@@ -334,7 +336,7 @@ async function agreementsInChat(services, chatId, cachedAgreements) {
334
336
  }
335
337
  try {
336
338
  // The room is the lane, as for every read made on a wake.
337
- const reader = services.contextReadClient.withLane?.(chatId) ?? services.contextReadClient;
339
+ const reader = services.contextReadClient;
338
340
  const snap = await reader.snapshot(chatId, { maxMessages: 1 });
339
341
  const ags = snap.agreements;
340
342
  return Array.isArray(ags) ? ags : [];
@@ -66,7 +66,11 @@ export const agreementProposeTool = defineTool('agreement_propose', {
66
66
  agreementDescription: 'string',
67
67
  }, handlerFor('agreement_propose'), { description: 'Propose an agreement — direct, broadcast, or link; there are no separate publish tools. Engagement precedence (posted-first): 1) REUSE an active agreement on matching terms, 2) CLAIM the counterparty\'s listing (marketplace_view → agreement_claim; never counter a listing), 3) POST a request broadcast here when nothing listed fits, 4) direct-propose ONLY for bespoke terms, renegotiation, or commissioning a named counterparty with no listing — most published agents are claim-only and refuse direct proposals with a pointer at their listing. 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), "org" (your org only) or "company" (every team in your company) and no providerId posts a REQUEST any agent may claim. A person or the deploy publishes a standing listing; this agent does not. For a bilateral trust link, use link_propose: it takes an email or an agent id, or neither for a share link. 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 });
68
68
  export const agreementSubcontractTool = defineTool('agreement_subcontract', agreementSubcontractCapability.params, handlerFor('agreement_subcontract'), { description: agreementSubcontractCapability.descriptions.sdk, isAgreementCreation: true });
69
- 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 (requests, 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.' });
69
+ export const agreementRespondTool = defineTool('agreement_respond', {
70
+ agreementId: { type: 'string', required: true },
71
+ action: { type: 'string', required: true, enum: ['approve', 'reject'] },
72
+ mandateAgreementId: { type: 'string', description: 'The hire you act under, when its setting lets you answer without a person.' },
73
+ }, handlerFor('agreement_respond'), { description: 'Approve or reject a pending DIRECT agreement proposal addressed to you. Open broadcasts (requests, 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.' });
70
74
  export const agreementClaimTool = defineTool('agreement_claim', agreementClaimCapability.params, handlerFor('agreement_claim'), { description: agreementClaimCapability.descriptions.sdk });
71
75
  export const taskSetResultTool = defineTool('task_set_result', {
72
76
  taskId: { type: 'string', required: true },
@@ -38,7 +38,7 @@ async function resolveTarget(ctx, toolName, connectionId, grantId) {
38
38
  // No explicit baseUrl: ConnectionsClient already resolves it the way the fleet
39
39
  // configures it (HTTP_URL). Passing BACKEND_URL here was the same mistake as in
40
40
  // the transport — undefined in deployment.
41
- const groups = await new ConnectionsClient(creds.operatorKey, creds.agentId, undefined, creds.laneId).listForHolder();
41
+ const groups = await new ConnectionsClient(creds.operatorKey, creds.agentId, undefined).listForHolder();
42
42
  const live = groups.filter((g) => (g.grants ?? []).length > 0);
43
43
  if (live.length === 0) {
44
44
  throw new Error('No live connection grant. Ask the connection owner to issue one for this agent, then retry.');
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition } from '../../../tools/defineTool.js';
2
2
  export declare const listTasksTool: ToolDefinition;
3
+ export declare const delegateTaskTool: ToolDefinition;
3
4
  export declare const cancelTaskTool: ToolDefinition;
4
5
  export declare const TASK_TOOLS: ToolDefinition[];
@@ -1,7 +1,8 @@
1
- import { cancelTaskCapability, listTasksCapability, TASK_CAPABILITIES } from '@ziggs-ai/api-client';
1
+ import { delegateTaskCapability, cancelTaskCapability, listTasksCapability, TASK_CAPABILITIES } from '@ziggs-ai/api-client';
2
2
  import { toolFromCapability, toolsFromCapabilities } from '../../../tools/capabilityAdapter.js';
3
3
  // GET /tasks, defined once in api-client. MCP ziggs_task_list and this
4
4
  // hosted-Claude tool used to re-declare the same filters.
5
5
  export const listTasksTool = toolFromCapability(listTasksCapability);
6
+ export const delegateTaskTool = toolFromCapability(delegateTaskCapability);
6
7
  export const cancelTaskTool = toolFromCapability(cancelTaskCapability);
7
8
  export const TASK_TOOLS = toolsFromCapabilities(TASK_CAPABILITIES);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.41.2",
3
+ "version": "0.42.2",
4
4
  "description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
5
5
  "homepage": "https://ziggsai.com/docs",
6
6
  "bugs": {
@@ -30,8 +30,8 @@
30
30
  "@anthropic-ai/claude-agent-sdk": "^0.3.284",
31
31
  "@anthropic-ai/sdk": "^0.110.0",
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
- "@ziggs-ai/api-client": "0.37.27",
34
- "@ziggs-ai/contracts": "^0.31.0",
33
+ "@ziggs-ai/api-client": "0.38.2",
34
+ "@ziggs-ai/contracts": "^0.33.1",
35
35
  "ajv": "^8.17.1",
36
36
  "dotenv": "^17.2.3",
37
37
  "openai": "^4.0.0",