@ziggs-ai/agent-sdk 0.42.0 → 0.43.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +1 -1
  3. package/dist/Agent.d.ts +27 -8
  4. package/dist/Agent.js +188 -98
  5. package/dist/brain/ClaudeBrain.d.ts +0 -1
  6. package/dist/brain/ClaudeBrain.js +18 -42
  7. package/dist/brain/claudeCognition.js +5 -6
  8. package/dist/brain/claudeSdkProtocol.js +9 -9
  9. package/dist/brain/claudeSdkTools.d.ts +5 -5
  10. package/dist/brain/claudeSdkTools.js +40 -135
  11. package/dist/brain/hostedToolSurface.js +1 -1
  12. package/dist/brain/standingOfferNotice.d.ts +0 -2
  13. package/dist/brain/standingOfferNotice.js +1 -21
  14. package/dist/brain/toolManagerToSdkMcp.js +1 -1
  15. package/dist/brain/wakeGate.d.ts +3 -1
  16. package/dist/brain/wakeGate.js +6 -0
  17. package/dist/index.d.ts +4 -3
  18. package/dist/index.js +4 -3
  19. package/dist/ingress/normalizeIncoming.d.ts +5 -3
  20. package/dist/ingress/normalizeIncoming.js +9 -8
  21. package/dist/mcp/createMcpAgent.d.ts +15 -0
  22. package/dist/mcp/createMcpAgent.js +47 -11
  23. package/dist/memory/MemoryStore.d.ts +1 -1
  24. package/dist/memory/MemoryStore.js +1 -6
  25. package/dist/platform/ConnectionPool.js +1 -1
  26. package/dist/platform/OutboxBuffer.d.ts +2 -2
  27. package/dist/platform/OutboxBuffer.js +17 -17
  28. package/dist/platform/StateTimers.d.ts +3 -1
  29. package/dist/platform/StateTimers.js +4 -3
  30. package/dist/platform/WakeLedger.d.ts +10 -6
  31. package/dist/platform/WakeLedger.js +16 -9
  32. package/dist/platform/ZiggsEffectHandler.d.ts +18 -4
  33. package/dist/platform/ZiggsEffectHandler.js +86 -55
  34. package/dist/platform/agreements/AgreementService.d.ts +1 -3
  35. package/dist/platform/agreements/AgreementService.js +1 -7
  36. package/dist/platform/inboxLoop.d.ts +5 -9
  37. package/dist/platform/inboxLoop.js +165 -158
  38. package/dist/platform/polls/PollKind.d.ts +13 -16
  39. package/dist/platform/polls/PollKind.js +4 -4
  40. package/dist/platform/polls/PollRunner.d.ts +3 -20
  41. package/dist/platform/polls/PollRunner.js +13 -15
  42. package/dist/platform/polls/declaredPollRunner.d.ts +1 -1
  43. package/dist/platform/polls/declaredPollRunner.js +1 -1
  44. package/dist/platform/tasks/TaskService.d.ts +4 -3
  45. package/dist/platform/tasks/TaskService.js +1 -4
  46. package/dist/platform/telemetryIngest.js +4 -4
  47. package/dist/platform/tools/index.d.ts +1 -2
  48. package/dist/platform/tools/index.js +1 -4
  49. package/dist/platform/tools/tier1/protocolRunner.d.ts +1 -1
  50. package/dist/platform/tools/tier1/protocolRunner.js +24 -17
  51. package/dist/platform/tools/tier1/protocolTools.js +2 -2
  52. package/dist/platform/tools/tier1/wakeTools.d.ts +1 -1
  53. package/dist/platform/tools/tier1/wakeTools.js +2 -1
  54. package/dist/platform/tools/tier2/mcpConnectionTools.js +1 -1
  55. package/dist/platform/tools/tier2/taskTools.d.ts +1 -0
  56. package/dist/platform/tools/tier2/taskTools.js +2 -1
  57. package/dist/runtime/AgentMachine.d.ts +2 -2
  58. package/dist/runtime/AgentMachine.js +4 -5
  59. package/dist/runtime/PromptBuilder.d.ts +1 -1
  60. package/dist/runtime/PromptBuilder.js +5 -4
  61. package/dist/runtime/buildOutcome.d.ts +1 -1
  62. package/dist/runtime/deliveryWriteKeys.d.ts +1 -1
  63. package/dist/runtime/deliveryWriteKeys.js +1 -1
  64. package/dist/runtime/kickoff.d.ts +14 -43
  65. package/dist/runtime/kickoff.js +22 -178
  66. package/dist/runtime/messageCommand.d.ts +9 -0
  67. package/dist/runtime/messageCommand.js +23 -0
  68. package/dist/runtime/requiredContext.d.ts +6 -0
  69. package/dist/runtime/requiredContext.js +23 -0
  70. package/dist/runtime/runTurn.js +31 -106
  71. package/dist/runtime/taskIntake.js +8 -22
  72. package/dist/runtime/wakeEffects.d.ts +3 -12
  73. package/dist/runtime/wakeEffects.js +17 -48
  74. package/dist/runtime/wakeOutcome.js +1 -1
  75. package/dist/skills/createSkillAgent.js +12 -1
  76. package/dist/tools/capabilityAdapter.js +1 -1
  77. package/dist/tools/recordReport.d.ts +1 -1
  78. package/dist/tools/recordReport.js +3 -3
  79. package/dist/tools/toolCreds.d.ts +1 -1
  80. package/dist/tools/toolCreds.js +4 -2
  81. package/dist/types.d.ts +17 -27
  82. package/package.json +3 -3
  83. package/dist/platform/tools/tier2/chatTools.d.ts +0 -3
  84. package/dist/platform/tools/tier2/chatTools.js +0 -7
  85. package/dist/probeChat.d.ts +0 -10
  86. package/dist/probeChat.js +0 -12
package/dist/Agent.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { runWorkExecution } from '@ziggs-ai/api-client';
2
+ import { MessageRecordsClient } from '@ziggs-ai/api-client/http';
3
+ import { responseCommand } from './runtime/messageCommand.js';
1
4
  import { validateWorkExecution, withWorkExecution, currentWorkExecution } from '@ziggs-ai/api-client';
2
5
  import { SERVER_EVENTS } from '@ziggs-ai/contracts';
3
6
  import { withAgreementContext } from './runtime/agreementContext.js';
4
- import { agreementLaneId, agreementIdFromLane, isContextAccessRequest } from '@ziggs-ai/api-client';
7
+ import { agreementLaneId, isContextAccessRequest } from '@ziggs-ai/api-client';
5
8
  import { createDeclaredPollRunner } from './platform/polls/declaredPollRunner.js';
6
- import { randomUUID } from 'node:crypto';
7
9
  import { AsyncLocalStorage } from 'node:async_hooks';
8
10
  import { contextScopesFromWake, WakeEffects, workScopeFromWake } from './runtime/wakeEffects.js';
9
11
  import { ExecutionController, asWorkTool, executionControllers } from './runtime/executionController.js';
@@ -16,7 +18,6 @@ import { CLAUDE_SDK_TOOLS } from './brain/claudeSdkTools.js';
16
18
  import { resolveClaudeModel } from './brain/claudeCognition.js';
17
19
  import { resolveFleetModel } from './shared/fleetModel.js';
18
20
  export { DEFAULT_MODEL, resolveFleetModel } from './shared/fleetModel.js';
19
- import { CHAT_TOOLS } from './platform/tools/tier2/chatTools.js';
20
21
  import { contextReadTool, openTool, accessExplainTool } from './platform/tools/tier2/contextTools.js';
21
22
  import { EventQueue } from './platform/EventQueue.js';
22
23
  import { StateTimers } from './platform/StateTimers.js';
@@ -35,7 +36,7 @@ import { PROTOCOL_TOOLS } from './platform/tools/tier1/protocolTools.js';
35
36
  import { marketplaceViewTool } from './platform/tools/tier2/marketplaceTools.js';
36
37
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
37
38
  import { AnthropicAdapter } from './adapters/AnthropicAdapter.js';
38
- import { ArtifactsClient, ConnectionsClient, ContextReadClient, TelemetryClient, sendChatMessage, presentSendResult, getAgreement, getTask, annotationForSdkTool, } from '@ziggs-ai/api-client';
39
+ import { ArtifactsClient, ConnectionsClient, ContextReadClient, TelemetryClient, getAgreement, getTask, annotationForSdkTool, } from '@ziggs-ai/api-client';
39
40
  import { isCheckoutNeed, normalizeNeeds, unmetNeeds, unmetNeedsMessage, } from './tools/needs.js';
40
41
  import { openCheckout, closeCheckout } from './checkout/open.js';
41
42
  import { resolveBackendUrl } from './shared/backendUrl.js';
@@ -56,20 +57,16 @@ async function loadConnectionPool() {
56
57
  const mod = await import(/* @vite-ignore */ modPath);
57
58
  return mod.ConnectionPool;
58
59
  }
59
- const DEFAULT_SESSION_RESOLVER = (normalized) => normalized.chatId;
60
+ const DEFAULT_SESSION_RESOLVER = (normalized) => normalized.addressId ?? (normalized.event.agreementId ? `agrn-${normalized.event.agreementId}` : normalized.event.messageId ? `msgn-${normalized.event.messageId}` : null);
60
61
  /**
61
62
  * one lane key per unit of work, derived the same way as inbox wakes.
62
63
  *
63
- * Inbox deliveries key on `chatId` (DEFAULT_SESSION_RESOLVER). Bootstrap used to
64
+ * Inbox deliveries use their room when present. Bootstrap used to
64
65
  * prefer bare `agreementId`, which opened a second lane for the same task and
65
- * made the snapshot read treat the agreement id as a chat (`chatMissing: true`,
66
- * empty context). Prefer the origin chat when present; otherwise the agreement's
67
- * notification chat (`agrn-<agreementId>`), which is always linked after.
66
+ * misdirected the snapshot read. Use the agreement's local processing key. An origin link is
67
+ * navigation, not a delivery destination. This key creates no chat or grant.
68
68
  */
69
69
  export function laneSessionIdForTask(task) {
70
- if (typeof task.originChatId === 'string' && task.originChatId) {
71
- return task.originChatId;
72
- }
73
70
  if (typeof task.agreementId === 'string' && task.agreementId) {
74
71
  return agreementLaneId(task.agreementId);
75
72
  }
@@ -86,7 +83,7 @@ const CHECKOUT_TICK_TIMEOUT_MS = Number(process.env.CLAUDE_SDK_CHECKOUT_TICK_TIM
86
83
  * Anything else is a custom tool, and under execution control a custom tool
87
84
  * does work: it gets a `taskId` the model fills (see asWorkTool).
88
85
  */
89
- const HAND_DEFINED_PLATFORM_TOOLS = new Set([...PROTOCOL_TOOLS, ...CHAT_TOOLS, ...CLAUDE_SDK_TOOLS, ...WAKE_TOOLS, marketplaceViewTool, contextReadTool, openTool, accessExplainTool]
86
+ const HAND_DEFINED_PLATFORM_TOOLS = new Set([...PROTOCOL_TOOLS, ...CLAUDE_SDK_TOOLS, ...WAKE_TOOLS, marketplaceViewTool, contextReadTool, openTool, accessExplainTool]
90
87
  .map((tool) => toolName(tool)));
91
88
  function customToolAsWork(tool) {
92
89
  const name = toolName(tool);
@@ -111,9 +108,6 @@ function createToolManager(options) {
111
108
  // Both directions: shedding claim (taskTools array without it) sheds browse.
112
109
  const canClaim = taskTools.some((t) => t.schema.function.name === 'agreement_claim');
113
110
  const browseTools = canClaim ? [marketplaceViewTool] : [];
114
- // chat_open is always-on so claim/provider kickoff (FSM) and the
115
- // Claude always-on set can open the buyer chat after a store claim. CHAT_TOOLS
116
- // is also folded into CLAUDE_SDK_TOOLS; Map registration makes duplicates a no-op.
117
111
  // wake_at stays registered for the FSM and for the hosted model, where it is
118
112
  // deferred behind tool search; an agent that can be handed a task
119
113
  // with a check-back rule but cannot arm a wake would have to busy-wait, and
@@ -126,7 +120,7 @@ function createToolManager(options) {
126
120
  const everydayContext = isClaudeCognition(options.cognition)
127
121
  ? [contextReadTool, openTool, accessExplainTool].filter((t) => !ownNames.has(toolName(t)))
128
122
  : [];
129
- const platformTools = [...taskTools, ...CHAT_TOOLS, ...browseTools, ...claudeSdkTools, ...everydayContext, ...WAKE_TOOLS];
123
+ const platformTools = [...taskTools, ...browseTools, ...claudeSdkTools, ...everydayContext, ...WAKE_TOOLS];
130
124
  warnOnShadowedPlatformTools(platformTools, ownTools, options.agentId);
131
125
  const controlOn = options.executionControl ?? isClaudeCognition(options.cognition);
132
126
  toolManager.registerAll([...platformTools, ...(controlOn ? ownTools.map(customToolAsWork) : ownTools)]);
@@ -241,6 +235,11 @@ export class Agent {
241
235
  /** Tasks left active by the asking turn, failed right after it (per lane). */
242
236
  unmetTasksPendingAsk = new Map();
243
237
  wakeEffects = new AsyncLocalStorage();
238
+ /**
239
+ * The roomless lane a live wake runs on. A tool's send or read reaches the
240
+ * host without the wake's effect wrapper, so both look the lane up here.
241
+ */
242
+ wakeDestination = new AsyncLocalStorage();
244
243
  outboundMessages = new SeenMessages();
245
244
  confirmedPlanVersions = new SeenMessages();
246
245
  grantProviderCache = null;
@@ -312,7 +311,7 @@ export class Agent {
312
311
  this.sessionStore = options.sessionStore ?? null;
313
312
  this._validate();
314
313
  this.pollRunner = createDeclaredPollRunner(this.options, {
315
- sendTo: (_agentId, text, metadata) => this.handleMessage(text, metadata ?? {}),
314
+ wakeTask: (_agentId, taskId) => this.wakeTaskAssignment(taskId),
316
315
  });
317
316
  this._setup();
318
317
  }
@@ -344,7 +343,7 @@ export class Agent {
344
343
  this.toolManager = services.toolManager;
345
344
  this.llm = services.llm;
346
345
  // Outbound send is HTTP (delivery law, phase 4): the agent no longer holds a
347
- // socket. `POST /chats/:id/messages` persists the whole message — the same
346
+ // socket. `POST /messaging/messages` persists the whole message — the same
348
347
  // row the WS path persisted. Live-token streaming had no HTTP path, so
349
348
  // `chunkSender` is intentionally dropped: partials degrade, the full reply
350
349
  // is never lost (runTurn always follows a stream with a whole send-message).
@@ -352,32 +351,7 @@ export class Agent {
352
351
  operatorKey: this.options.operatorKey,
353
352
  agentId: this.options.agentId,
354
353
  };
355
- const messageSender = async (message, receiverId, sessionId, messageId, conversationAgreementId, underAgreementId, taskId, sources) => {
356
- const sentId = messageId ?? `sdk_${randomUUID()}`;
357
- const result = await sendChatMessage({
358
- chatId: sessionId,
359
- // Omitted on purpose when the caller has no valid address for THIS
360
- // room: the backend infers the recipient from the room's own
361
- // participants.
362
- receiverId,
363
- text: message,
364
- // messageId is the backend's (chatId, messageId) dedup key. Callers
365
- // pass a wake-derived id for replay-stability; fall back to a fresh id
366
- // only when absent (matches the old WS path letting the server assign).
367
- messageId: sentId,
368
- conversationAgreementId,
369
- underAgreementId,
370
- taskId,
371
- ...(sources ? { sources } : {}),
372
- }, creds);
373
- if (!result.success)
374
- throw new Error('sendChatMessage: the server did not accept this message — nothing was delivered and nobody was woken. Retry with the same messageId, which the backend dedups on.');
375
- this.outboundMessages.mark(JSON.stringify([result.chatId, result.messageId]));
376
- // Presented once, here, so every hosted send — FSM reply, brain tool,
377
- // stream follow-up — reports the destination the same way the MCP
378
- // surface does.
379
- return { ...result, ...presentSendResult(result, { creds, surface: 'sdk' }) };
380
- };
354
+ const messageSender = (command) => new MessageRecordsClient(creds.operatorKey, creds.agentId, resolveBackendUrl()).send(command);
381
355
  this.taskService = services.taskService;
382
356
  this.agreementService = services.agreementService;
383
357
  this.telemetryClient = services.telemetryClient;
@@ -413,6 +387,10 @@ export class Agent {
413
387
  taskService: services.taskService,
414
388
  agreementService: services.agreementService,
415
389
  messageSender,
390
+ conversationFor: (sessionId) => {
391
+ const lane = this.wakeDestination.getStore();
392
+ return lane?.sessionId === sessionId ? lane : undefined;
393
+ },
416
394
  toolManager: services.toolManager,
417
395
  operatorKey: this.options.operatorKey,
418
396
  agentId: this.options.agentId,
@@ -467,17 +445,17 @@ export class Agent {
467
445
  promptBuilder: services.promptBuilder,
468
446
  });
469
447
  this.eventQueue = new EventQueue((event, laneKey) => this._processEvent(event, laneKey));
470
- // A timer frame is not a delivery: there is no watermark waiting on it and
471
- // nothing to redeliver, so its failure is logged and dropped here rather
472
- // than left as an unhandled rejection (made enqueue reject).
448
+ // Timer dispatch is at-least-once. Only successful work checkpoints this
449
+ // exact wake; a crash/failure leaves it durable, and an older completion
450
+ // cannot consume a timer armed while that work was running.
473
451
  this.stateTimers = new StateTimers((ev, laneKey) => {
474
- // A check-back that fired is no longer waiting: a restart from here on
475
- // must not fire it again.
476
- if (ev.type === 'wake-at')
477
- this.wakeLedger.done(laneKey);
478
- void this.eventQueue.enqueue(ev, laneKey).catch((err) => {
452
+ const work = this.eventQueue.enqueue(ev, laneKey).then(() => {
453
+ if (ev.type === 'wake-at')
454
+ this.wakeLedger.done(laneKey, ev.wakeId);
455
+ }).catch((err) => {
479
456
  runtimeLog.warn('Agent', `state-timer frame failed laneKey=${laneKey}: ${err?.message || err}`);
480
457
  });
458
+ this.wakeLedger.track(work);
481
459
  });
482
460
  }
483
461
  executionControlOn() {
@@ -522,8 +500,10 @@ export class Agent {
522
500
  if (!this.taskService)
523
501
  return null;
524
502
  let task;
503
+ // Host control observation is separate from task work. Only execution state
504
+ // leaves this method; a stopped work context cannot observe its own stop.
525
505
  try {
526
- task = await this.taskService.getTask(taskId);
506
+ task = await withWorkExecution(undefined, () => this.taskService.getTask(taskId));
527
507
  }
528
508
  catch (error) {
529
509
  // Unreadable is not a hold. The wake filter still drops a frame whose
@@ -537,6 +517,17 @@ export class Agent {
537
517
  return null;
538
518
  return { blocked: task.state === 'cancelled' || (task.state === 'active' && task.control?.action === 'pause'), closed: task.state === 'cancelled', control: task.control };
539
519
  }
520
+ /**
521
+ * Control on an assignment the inbox will not run yet. No wake follows, so a
522
+ * resume is acknowledged here too once no run of the task is live; the wake
523
+ * path that would otherwise answer it never comes while the delivery is blocked.
524
+ */
525
+ async observeBlockedTaskControl(taskId) {
526
+ const execution = await this.taskExecutionControl(taskId);
527
+ const idleResume = execution?.control?.action === 'resume' && !this.activeTaskRuns.get(taskId)?.size;
528
+ if (execution?.blocked || idleResume)
529
+ await this.acknowledgeExecutionControl(taskId, execution?.control);
530
+ }
540
531
  async acknowledgeExecutionControl(taskId, control) {
541
532
  if (control?.status !== 'requested')
542
533
  return;
@@ -605,15 +596,15 @@ export class Agent {
605
596
  }
606
597
  // Map the event to a candidate session key using the configured resolver
607
598
  // on a synthetic normalized-event shape. Default resolver buckets by
608
- // chatId; agents using a different sessionResolver (agreementId, taskId,
599
+ // addressId; agents using a different sessionResolver (agreementId, taskId,
609
600
  // counterpartyId) will get the right lane here as long as the wire
610
601
  // event carries the corresponding id.
611
602
  const fakeNormalized = {
612
- chatId: event.chatId,
603
+ addressId: event.addressId,
613
604
  event: { type: 'message', timestamp: Date.now(), text: '' },
614
605
  };
615
606
  let sessionId = this.sessionResolver(fakeNormalized, ownAgentId);
616
- // store claims deliver agreement wakes with no chatId. Do not
607
+ // store claims deliver agreement wakes with no addressId. Do not
617
608
  // drop them; lane by agreement the same way task bootstrap does
618
609
  // (`agrn-<agreementId>`), never a bare agreement id.
619
610
  if (!sessionId) {
@@ -641,10 +632,10 @@ export class Agent {
641
632
  return this.eventQueue.enqueue({
642
633
  type: SERVER_EVENTS.resourceChanged,
643
634
  resource: event,
644
- chatId: event.chatId,
635
+ addressId: event.addressId,
645
636
  taskId: event.taskId,
646
637
  agreementId,
647
- // Claimer/buyer — chat_send defaults receiverId from senderId.
638
+ // Claimer/buyer — the canonical reply command explicitly names the requester.
648
639
  ...(actorId ? { senderId: actorId } : {}),
649
640
  }, laneKey);
650
641
  }
@@ -658,15 +649,27 @@ export class Agent {
658
649
  ['proposal_approved', 'proposal_rejected'].includes(notice?.operation ?? '') &&
659
650
  isContextAccessRequest(notice?.agreement?.grantRequest))
660
651
  return;
661
- const messageId = (metadata.responsibilityId ?? metadata.messageId);
652
+ const messageId = (metadata.receiptId ?? metadata.messageId);
662
653
  if (messageId && this.seenMessages.has(messageId)) {
663
654
  runtimeLog.debug('Agent', `skip duplicate messageId=${messageId}`);
664
655
  return;
665
656
  }
666
657
  if (messageId)
667
658
  this.seenMessages.mark(messageId);
659
+ // The inbox marks a message that has no room. It has no other way back to
660
+ // the agent, so one that cannot be routed here stays pending, never dropped.
661
+ const roomless = metadata.roomless === true && !metadata.addressId;
662
+ const roomlessAgreementId = metadata.roomless === true && !metadata.addressId
663
+ && typeof metadata.agreementId === 'string' && metadata.agreementId ? metadata.agreementId : undefined;
664
+ const unroutable = (why) => {
665
+ if (messageId)
666
+ this.seenMessages.unmark(messageId);
667
+ return new Error(`handleMessage: message ${messageId ?? '(no id)'} ${roomlessAgreementId ? `under agreement ${roomlessAgreementId}` : 'with an exact action'} has no address and ${why}. Left pending.`);
668
+ };
668
669
  const ownAgentId = this._resolveOwnAgentId();
669
670
  if (!ownAgentId) {
671
+ if (roomless)
672
+ throw unroutable('ownAgentId is missing');
670
673
  runtimeLog.warn('Agent', 'handleMessage: ownAgentId missing — dropping event');
671
674
  return;
672
675
  }
@@ -687,7 +690,9 @@ export class Agent {
687
690
  const normalized = normalizeIncomingEvent({ text: enrichedText, metadata, ownAgentId });
688
691
  const sessionId = this.sessionResolver(normalized, ownAgentId);
689
692
  if (!sessionId) {
690
- runtimeLog.warn('Agent', `session resolver returned null ownAgentId=${ownAgentId} chatId=${normalized.chatId ?? 'null'}`);
693
+ if (roomless)
694
+ throw unroutable('the session resolver returned no lane for it');
695
+ runtimeLog.warn('Agent', `session resolver returned null ownAgentId=${ownAgentId} addressId=${normalized.addressId ?? 'null'}`);
691
696
  return;
692
697
  }
693
698
  const executionContext = metadata.executionContext;
@@ -695,6 +700,8 @@ export class Agent {
695
700
  if (!this.sessions.has(laneKey)) {
696
701
  this.sessions.set(laneKey, { sessionId, ownAgentId, executionContext });
697
702
  }
703
+ if (roomlessAgreementId)
704
+ this.sessions.get(laneKey).roomlessAgreementId = roomlessAgreementId;
698
705
  // The inbox loop owns ack (act-before-ack, delivery law); the agent only
699
706
  // routes the event onto its lane. No live-path watermark ack — there is no
700
707
  // separate live/push channel to reconcile against anymore.
@@ -703,7 +710,7 @@ export class Agent {
703
710
  // the same message being processed twice; leaving it set on a failed wake
704
711
  // would make the redelivery this rejection buys us a no-op.
705
712
  try {
706
- await this.eventQueue.enqueue({ ...normalized.event, chatId: normalized.chatId }, laneKey);
713
+ await this.eventQueue.enqueue({ ...normalized.event, addressId: normalized.addressId }, laneKey);
707
714
  }
708
715
  catch (err) {
709
716
  if (messageId)
@@ -797,8 +804,7 @@ export class Agent {
797
804
  // Fail open: a lookup error must not drop a live hire's session.
798
805
  }
799
806
  }
800
- async laneAgreementIsTerminal(sessionId) {
801
- const agreementId = agreementIdFromLane(sessionId);
807
+ async agreementIsTerminal(agreementId) {
802
808
  if (!agreementId || !this.agreementService)
803
809
  return false;
804
810
  try {
@@ -822,8 +828,10 @@ export class Agent {
822
828
  * Every rule here reads a row or the frame itself. None of them reads a lane,
823
829
  * an agent kind, a name or an id shape.
824
830
  */
825
- async wakeSuppression(event, sessionId) {
826
- if (await this.laneAgreementIsTerminal(sessionId)) {
831
+ async wakeSuppression(event, _sessionId) {
832
+ const execution = currentWorkExecution();
833
+ const agreementId = execution ? execution.agreementId : workScopeFromWake(event).agreementId;
834
+ if (await this.agreementIsTerminal(agreementId)) {
827
835
  return { reason: 'agreement_terminal', because: 'its agreement is terminal' };
828
836
  }
829
837
  if (this.isSettledTaskEcho(event)) {
@@ -864,7 +872,7 @@ export class Agent {
864
872
  *
865
873
  * A task frame that carries a terminal state AND a result is an echo of work
866
874
  * this agent has already finished: there is no decision left in it. Dropping it
867
- * is not the same suppression as {@link laneAgreementIsTerminal}, which waits
875
+ * is not the same suppression as {@link agreementIsTerminal}, which waits
868
876
  * for the agreement to end — this fires while the engagement is still live,
869
877
  * which is where the cost actually was.
870
878
  *
@@ -1033,17 +1041,74 @@ export class Agent {
1033
1041
  const tools = this.options.tools ?? this.options.services?.tools ?? [];
1034
1042
  return tools.some((t) => toolName(t) === 'connection_request');
1035
1043
  }
1044
+ /**
1045
+ * The work destination comes from the selected execution first, then the
1046
+ * structured wake. It does not depend on an earlier message being delivered
1047
+ * to this host, so task-first and restarted/scheduled runs work identically.
1048
+ * A reply defaults to the message that woke it, and to the wake's task.
1049
+ * A lane name is never an address: a conversation comes from a message.
1050
+ */
1051
+ wakeDestinationFor(session, frame, marks) {
1052
+ const asFrame = (value) => value && typeof value === 'object' ? value : {};
1053
+ const text = (value) => typeof value === 'string' && value ? value : undefined;
1054
+ const execution = session.executionContext;
1055
+ const wake = asFrame(frame);
1056
+ const events = wake.type === 'batch' && Array.isArray(wake.events) ? wake.events.map(asFrame) : undefined;
1057
+ // The shared conversations this wake's own messages arrived in.
1058
+ const rooms = [...new Set((events ?? [wake]).filter(e => e.type === 'message').map(e => text(e.addressId)).filter((id) => !!id))];
1059
+ const room = rooms.length === 1 ? rooms[0] : undefined;
1060
+ // A direct execution exists to answer one exact message.
1061
+ const actionMessageId = execution && !execution.agreementId && execution.action?.kind === 'message' ? text(execution.action.id) : undefined;
1062
+ // Agreement work answers in its own conversation; only a lane without work
1063
+ // execution, or a direct execution, keeps the room its messages came from.
1064
+ const remembers = !execution || !!actionMessageId;
1065
+ if (remembers && rooms.length)
1066
+ session.addressId = room && (session.addressId === undefined || session.addressId === room) ? room : null;
1067
+ const remembered = remembers ? session.addressId ?? undefined : undefined;
1068
+ const explicitAgreement = text(wake.agreementId) ?? text(wake.resource?.agreementId) ?? text(wake.result?.agreementId)
1069
+ ?? (events ? marks.scope.agreementId : undefined);
1070
+ const explicitChat = text(wake.addressId) ?? text(wake.resource?.addressId);
1071
+ // An exact incoming message supplies its reply address, and a batch of
1072
+ // messages from one room keeps that room; from several rooms the agent
1073
+ // names one. Work execution supplies authority, never a transport
1074
+ // default: under it, other events' navigation hints select no chat.
1075
+ const addressId = wake.type === 'message' ? explicitChat
1076
+ : events ? room ?? (rooms.length ? undefined : remembered)
1077
+ : execution ? remembered
1078
+ : explicitChat ?? remembered;
1079
+ // An unscoped batch cannot borrow a previous customer's agreement.
1080
+ const agreementId = execution ? execution.agreementId ?? undefined
1081
+ : explicitAgreement ?? (events ? undefined : session.roomlessAgreementId);
1082
+ const message = wake.type === 'message' && text(wake.messageId)
1083
+ ? { messageId: wake.messageId, ...(text(wake.senderId) ? { senderId: wake.senderId } : {}), ...(text(wake.taskId) ? { taskId: wake.taskId } : {}) }
1084
+ : actionMessageId ? { messageId: actionMessageId } : undefined;
1085
+ const inputRooms = rooms.length > 1 ? rooms : undefined;
1086
+ if (!agreementId && !addressId && !message && !inputRooms)
1087
+ return undefined;
1088
+ const taskId = execution ? execution.taskId ?? undefined : marks.scope.taskId;
1089
+ return {
1090
+ sessionId: session.sessionId,
1091
+ agreementId,
1092
+ addressId,
1093
+ ...(message ? { message } : {}),
1094
+ ...(taskId ? { taskId } : {}),
1095
+ ...(inputRooms ? { inputRooms } : {}),
1096
+ };
1097
+ }
1036
1098
  async _processEvent(event, laneKey) {
1037
- return withWorkExecution(this.sessions.get(laneKey)?.executionContext, () => this._processEventInContext(event, laneKey));
1099
+ const context = this.sessions.get(laneKey)?.executionContext;
1100
+ if (!context)
1101
+ return this._processEventInContext(event, laneKey);
1102
+ return runWorkExecution(context, this.options.operatorKey, () => this._processEventInContext(event, laneKey), resolveBackendUrl());
1038
1103
  }
1039
1104
  async _processEventInContext(event, laneKey) {
1040
1105
  const session = this.sessions.get(laneKey);
1041
1106
  if (!session?.sessionId)
1042
1107
  return;
1043
1108
  if (session.executionContext)
1044
- await validateWorkExecution(session.executionContext, this.options.operatorKey, resolveBackendUrl());
1109
+ await validateWorkExecution(currentWorkExecution() ?? session.executionContext, this.options.operatorKey, resolveBackendUrl());
1045
1110
  // Presentation may call our sender an rpb-* room binding or the person we
1046
- // stand in for. Only an exact (chat, messageId) we successfully sent proves
1111
+ // stand in for. Only an exact canonical messageId we successfully sent proves
1047
1112
  // an echo; another message from the same binding still gets a full wake.
1048
1113
  const incoming = event;
1049
1114
  const events = incoming?.type === 'batch' && Array.isArray(incoming.events) ? incoming.events : [event];
@@ -1054,7 +1119,7 @@ export class Agent {
1054
1119
  && e?.source === 'active-task-snapshot' && (task?.state === 'active' || task?.state === 'in-progress')
1055
1120
  && task?.assigneeId === this.options.agentId && task.taskId && task.updatedAt && this.confirmedPlanVersions.has(JSON.stringify([laneKey, JSON.stringify([task.taskId, task.updatedAt])])))
1056
1121
  return false;
1057
- return e?.type !== 'message' || !e.messageId || !this.outboundMessages.has(JSON.stringify([e.chatId ?? session.sessionId, e.messageId]));
1122
+ return e?.type !== 'message' || !e.messageId || !this.outboundMessages.has(e.messageId);
1058
1123
  });
1059
1124
  if (remaining.length === 0) {
1060
1125
  this.recordWakeOutcome(session, { kind: 'no-op', reason: events.some(e => e?.type === 'task_result') ? 'own_plan_snapshot' : 'own_message_echo' });
@@ -1179,7 +1244,9 @@ export class Agent {
1179
1244
  // a zombie — burning tokens and racing the redelivered retry of the same
1180
1245
  // event (duplicate sends, runaway per-wake input).
1181
1246
  const tickAbort = new AbortController();
1182
- const marks = WakeEffects.forWake(frame, session.sessionId);
1247
+ const marks = session.executionContext
1248
+ ? new WakeEffects({ agreementId: session.executionContext.agreementId ?? undefined, taskId: session.executionContext.taskId ?? undefined }, false)
1249
+ : WakeEffects.forWake(frame, session.sessionId);
1183
1250
  const executionController = this.executionController(identity, tickAbort.signal, taskId => registerTaskRun(taskId));
1184
1251
  let heldTask;
1185
1252
  const pendingEffects = new Set();
@@ -1205,6 +1272,8 @@ export class Agent {
1205
1272
  taskRuns.clear();
1206
1273
  };
1207
1274
  const checkpoint = async (namedTaskId) => {
1275
+ if (session.executionContext)
1276
+ await validateWorkExecution(currentWorkExecution() ?? session.executionContext, this.options.operatorKey, resolveBackendUrl());
1208
1277
  if (tickAbort.signal.aborted) {
1209
1278
  if (tickAbort.signal.reason?.code === 'TASK_CONTROL')
1210
1279
  heldTask = tickAbort.signal.reason;
@@ -1263,11 +1332,9 @@ export class Agent {
1263
1332
  const sameRoom = 'sessionId' in effect && effect.sessionId === session.sessionId;
1264
1333
  const scopedEffect = sameRoom && effect.kind === 'read-context'
1265
1334
  ? { ...effect, opts: { ...effect.opts, workScopes: contextScopesFromWake(frame, session.sessionId) } }
1266
- : agreementId && effect.kind === 'send-message'
1267
- ? { ...effect, underAgreementId: effect.underAgreementId ?? agreementId }
1268
- : agreementId && (effect.kind === 'tool-call' || effect.kind === 'record-event')
1269
- ? { ...effect, serviceAgreementId: effect.serviceAgreementId ?? agreementId }
1270
- : effect;
1335
+ : agreementId && (effect.kind === 'tool-call' || effect.kind === 'record-event')
1336
+ ? { ...effect, serviceAgreementId: effect.serviceAgreementId ?? agreementId }
1337
+ : effect;
1271
1338
  const operation = this.eff(scopedEffect);
1272
1339
  pendingEffects.add(operation);
1273
1340
  const response = await operation.finally(() => { pendingEffects.delete(operation); });
@@ -1278,7 +1345,8 @@ export class Agent {
1278
1345
  };
1279
1346
  try {
1280
1347
  await checkpoint();
1281
- const tick = () => this.wakeEffects.run(marks, () => this.brain.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff, memory, transitionTap: this.options.transitionTap, signal: tickAbort.signal, ...(checkout ? { checkout: checkout.session } : {}) }));
1348
+ const roomless = this.wakeDestinationFor(session, frame, marks);
1349
+ const tick = () => this.wakeDestination.run(roomless, () => this.wakeEffects.run(marks, () => this.brain.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff, memory, transitionTap: this.options.transitionTap, signal: tickAbort.signal, ...(checkout ? { checkout: checkout.session } : {}) })));
1282
1350
  tickWork = executionController ? executionControllers.run(executionController, tick) : tick();
1283
1351
  const result = await Promise.race([
1284
1352
  tickWork,
@@ -1289,6 +1357,9 @@ export class Agent {
1289
1357
  }, checkout ? Math.max(this.tickTimeoutMs, CHECKOUT_TICK_TIMEOUT_MS) : this.tickTimeoutMs);
1290
1358
  }),
1291
1359
  ]);
1360
+ // Protected writes validate at their owner. A successful turn may have
1361
+ // completed its own task/answer, so post-turn admission would reject success.
1362
+ // Retained state is validated again before the next turn.
1292
1363
  session.ctx = result.ctx;
1293
1364
  this.carryExecutionState(session.ctx, executionController);
1294
1365
  session.state = result.state;
@@ -1397,8 +1468,8 @@ export class Agent {
1397
1468
  const ev = event;
1398
1469
  const senderType = String(ev?.senderType ?? '').toUpperCase();
1399
1470
  const senderId = ev?.senderId;
1400
- if (senderId && senderType === 'USER') {
1401
- this.eff({ kind: 'send-message', sessionId: session.sessionId, receiverId: senderId, text: 'Something went wrong. Please try again.' }).catch(() => { });
1471
+ if (senderId && senderType === 'USER' && err?.code !== 'REQUIRED_CONTEXT_UNAVAILABLE') {
1472
+ responseCommand(this.eff, session.sessionId, 'Something went wrong. Please try again.', { recipientId: senderId }).then(command => this.eff({ kind: 'send-message', sessionId: session.sessionId, command })).catch(() => { });
1402
1473
  }
1403
1474
  // and then say so. Everything above is recovery for the lane —
1404
1475
  // it does not mean the event was handled, and the caller is about to
@@ -1495,13 +1566,15 @@ export class Agent {
1495
1566
  * Borrowing that path would make a dev tool able to disturb a live one.
1496
1567
  */
1497
1568
  async replayWake(record) {
1498
- return withWorkExecution(record.executionContext, () => this.replayWakeInContext(record));
1569
+ if (!record.executionContext)
1570
+ return this.replayWakeInContext(record);
1571
+ return runWorkExecution(record.executionContext, this.options.operatorKey, () => this.replayWakeInContext(record), resolveBackendUrl());
1499
1572
  }
1500
1573
  async replayWakeInContext(record) {
1501
1574
  if (!record?.sessionId)
1502
1575
  throw new Error('replayWake: record.sessionId is required');
1503
1576
  if (record.executionContext)
1504
- await validateWorkExecution(record.executionContext, this.options.operatorKey, resolveBackendUrl());
1577
+ await validateWorkExecution(currentWorkExecution() ?? record.executionContext, this.options.operatorKey, resolveBackendUrl());
1505
1578
  const identity = {
1506
1579
  agentId: record.agentId || this.options.agentId,
1507
1580
  sessionId: record.sessionId,
@@ -1522,11 +1595,14 @@ export class Agent {
1522
1595
  // Same watchdog as a live wake, for the same reason: an unaborted Claude
1523
1596
  // query outlives the promise that abandoned it and keeps spending.
1524
1597
  const abort = new AbortController();
1525
- const marks = WakeEffects.forWake(record.event, identity.sessionId);
1598
+ const marks = record.executionContext
1599
+ ? new WakeEffects({ agreementId: record.executionContext.agreementId ?? undefined, taskId: record.executionContext.taskId ?? undefined }, false)
1600
+ : WakeEffects.forWake(record.event, identity.sessionId);
1601
+ const destination = this.wakeDestinationFor({ sessionId: record.sessionId, ownAgentId: identity.agentId, executionContext: record.executionContext }, record.event, marks);
1526
1602
  const controller = this.executionController(identity, abort.signal);
1527
1603
  let watchdog;
1528
1604
  try {
1529
- const tick = () => this.wakeEffects.run(marks, () => this.brain.tick({
1605
+ const tick = () => this.wakeDestination.run(destination, () => this.wakeEffects.run(marks, () => this.brain.tick({
1530
1606
  event: record.event,
1531
1607
  ctx: record.ctx,
1532
1608
  state: record.state,
@@ -1535,7 +1611,7 @@ export class Agent {
1535
1611
  memory,
1536
1612
  transitionTap: this.options.transitionTap,
1537
1613
  signal: abort.signal,
1538
- }));
1614
+ })));
1539
1615
  const result = await Promise.race([
1540
1616
  controller ? executionControllers.run(controller, tick) : tick(),
1541
1617
  new Promise((_, reject) => {
@@ -1612,12 +1688,13 @@ export class Agent {
1612
1688
  onResourceEvent: (event) => {
1613
1689
  return this.handleResourceEvent(event);
1614
1690
  },
1691
+ onTaskControlCheck: (taskId) => this.observeBlockedTaskControl(taskId),
1615
1692
  // work delivered by the inbox's task channel, not by a chat
1616
1693
  // row — this is what reaches a task under a chat-less agreement or one
1617
1694
  // the agent assigned to itself.
1618
- onTask: async (task) => {
1695
+ onTask: async (task, inputs) => {
1619
1696
  try {
1620
- await this.wakeTaskAssignment(task.taskId, { fromInbox: true });
1697
+ await this.wakeTaskAssignment(task.taskId, { fromInbox: true, inputs });
1621
1698
  }
1622
1699
  catch (err) {
1623
1700
  runtimeLog.warn(this.options.name || 'agent', `inbox task ${task.taskId} could not be woken: ${err.message}`);
@@ -1645,13 +1722,18 @@ export class Agent {
1645
1722
  const laneKey = laneKeyFor(this.options.agentId, sessionId);
1646
1723
  const scope = this.wakeEffects.getStore()?.scope;
1647
1724
  const armed = this.stateTimers.armWakeAt(laneKey, at, reason, scope);
1725
+ // The conversation the lane's messages came from, so a restarted host
1726
+ // answers there too instead of reading the lane name as an address.
1727
+ const addressId = this.sessions.get(laneKey)?.addressId;
1648
1728
  this.wakeLedger.arm({
1649
1729
  laneKey,
1650
1730
  sessionId,
1651
1731
  firesAt: armed.firesAt,
1732
+ wakeId: armed.wakeId,
1652
1733
  reason,
1653
1734
  ...(scope ? { scope } : {}),
1654
- executionContext: currentWorkExecution(),
1735
+ ...(addressId ? { addressId } : {}),
1736
+ executionContext: currentWorkExecution() ? { ...currentWorkExecution(), attemptId: undefined } : undefined,
1655
1737
  });
1656
1738
  return { ...armed, durable: this.wakeLedger.durable };
1657
1739
  }
@@ -1679,10 +1761,10 @@ export class Agent {
1679
1761
  if (this.stateTimers.peek(wake.laneKey))
1680
1762
  continue;
1681
1763
  if (!this.sessions.has(wake.laneKey)) {
1682
- this.sessions.set(wake.laneKey, { sessionId: wake.sessionId, ownAgentId: agentId, executionContext: wake.executionContext });
1764
+ this.sessions.set(wake.laneKey, { sessionId: wake.sessionId, ownAgentId: agentId, executionContext: wake.executionContext, ...(wake.addressId ? { addressId: wake.addressId } : {}) });
1683
1765
  }
1684
1766
  try {
1685
- this.stateTimers.armWakeAt(wake.laneKey, wake.firesAt, wake.reason, wake.scope);
1767
+ this.stateTimers.armWakeAt(wake.laneKey, wake.firesAt, wake.reason, wake.scope, wake.wakeId);
1686
1768
  runtimeLog.info(this.options.name || 'agent', `re-armed a saved wake laneKey=${wake.laneKey} at=${new Date(wake.firesAt).toISOString()}`);
1687
1769
  }
1688
1770
  catch (err) {
@@ -1705,11 +1787,17 @@ export class Agent {
1705
1787
  }
1706
1788
  const needle = request.match.trim();
1707
1789
  const title = request.title?.trim() || needle;
1708
- await this.handleMessage(`New marketplace request matching your tags (${needle}): ${title}. Claim with agreement_claim if you will take it.`, {
1709
- messageId: `request-wake:${request.agreementId}:${request.ts}`,
1710
- chatId: `marketplace:${request.agreementId}`,
1711
- agreementId: request.agreementId,
1712
- });
1790
+ // The request is read by its agreement. It has no message and no
1791
+ // conversation, so the wake names neither.
1792
+ try {
1793
+ await this.handleMessage(`New marketplace request matching your tags (${needle}): ${title}. Claim with agreement_claim if you will take it.`, { agreementId: request.agreementId });
1794
+ }
1795
+ catch (err) {
1796
+ // An open request is an offer to look, not assigned work, and it stays
1797
+ // open in the marketplace. A wake that could not read it must not hold
1798
+ // every other delivery in the pass behind it.
1799
+ runtimeLog.warn(this.options.name || 'agent', `request ${request.agreementId}: wake failed, left open in the marketplace: ${err?.message || err}`);
1800
+ }
1713
1801
  }
1714
1802
  /** replay task-assigned for an existing active task (demo recovery). */
1715
1803
  async wakeTaskAssignment(taskId, options) {
@@ -1722,21 +1810,23 @@ export class Agent {
1722
1810
  if (state !== 'active' && state !== 'in-progress') {
1723
1811
  return;
1724
1812
  }
1813
+ const selected = currentWorkExecution();
1725
1814
  const sessionId = laneSessionIdForTask(full);
1726
- const laneKey = laneKeyFor(agentId, sessionId);
1815
+ const laneKey = laneKeyFor(agentId, sessionId, selected?.contextId);
1727
1816
  if (!this.sessions.has(laneKey)) {
1728
1817
  this.sessions.set(laneKey, { sessionId, ownAgentId: agentId, executionContext: currentWorkExecution() });
1729
1818
  }
1730
1819
  await this.eventQueue.enqueue({
1731
1820
  type: 'task_result',
1732
- ...(options?.fromInbox ? { source: 'active-task-snapshot' } : {}),
1821
+ // New facts must not disappear behind an unchanged task snapshot version.
1822
+ ...(options?.fromInbox && !options.inputs?.length ? { source: 'active-task-snapshot' } : {}),
1733
1823
  result: {
1734
1824
  ...full,
1825
+ ...(options?.inputs?.length ? { inputs: options.inputs } : {}),
1735
1826
  taskId: full.taskId ?? taskId,
1736
1827
  state,
1737
1828
  providerIsYou: true,
1738
1829
  },
1739
- chatId: sessionId,
1740
1830
  }, laneKey);
1741
1831
  }
1742
1832
  /**
@@ -5,7 +5,6 @@ import type { ToolManager } from '../tools/ToolManager.js';
5
5
  import { type RawEvent } from '../runtime/buildOutcome.js';
6
6
  import type { TickInput, TickOutput } from './ZiggsBrain.js';
7
7
  import { type AskWakeGate } from './wakeGate.js';
8
- export { isPublishProbeChat } from '../probeChat.js';
9
8
  /**
10
9
  * status notices must not spend a Claude turn answering
11
10
  * nobody. Real work wakes arrive as chat `message`, synthetic `task_result`