@ziggs-ai/agent-sdk 0.42.2 → 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 (84) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +1 -1
  3. package/dist/Agent.d.ts +21 -8
  4. package/dist/Agent.js +173 -97
  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 +3 -3
  10. package/dist/brain/claudeSdkTools.js +37 -133
  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 +3 -9
  37. package/dist/platform/inboxLoop.js +151 -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 +0 -1
  48. package/dist/platform/tools/index.js +0 -3
  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/runtime/AgentMachine.d.ts +2 -2
  56. package/dist/runtime/AgentMachine.js +4 -5
  57. package/dist/runtime/PromptBuilder.d.ts +1 -1
  58. package/dist/runtime/PromptBuilder.js +5 -4
  59. package/dist/runtime/buildOutcome.d.ts +1 -1
  60. package/dist/runtime/deliveryWriteKeys.d.ts +1 -1
  61. package/dist/runtime/deliveryWriteKeys.js +1 -1
  62. package/dist/runtime/kickoff.d.ts +14 -43
  63. package/dist/runtime/kickoff.js +22 -178
  64. package/dist/runtime/messageCommand.d.ts +9 -0
  65. package/dist/runtime/messageCommand.js +23 -0
  66. package/dist/runtime/requiredContext.d.ts +6 -0
  67. package/dist/runtime/requiredContext.js +23 -0
  68. package/dist/runtime/runTurn.js +31 -106
  69. package/dist/runtime/taskIntake.js +8 -22
  70. package/dist/runtime/wakeEffects.d.ts +3 -12
  71. package/dist/runtime/wakeEffects.js +17 -48
  72. package/dist/runtime/wakeOutcome.js +1 -1
  73. package/dist/skills/createSkillAgent.js +12 -1
  74. package/dist/tools/capabilityAdapter.js +1 -1
  75. package/dist/tools/recordReport.d.ts +1 -1
  76. package/dist/tools/recordReport.js +3 -3
  77. package/dist/tools/toolCreds.d.ts +1 -1
  78. package/dist/tools/toolCreds.js +4 -2
  79. package/dist/types.d.ts +17 -27
  80. package/package.json +3 -3
  81. package/dist/platform/tools/tier2/chatTools.d.ts +0 -3
  82. package/dist/platform/tools/tier2/chatTools.js +0 -7
  83. package/dist/probeChat.d.ts +0 -10
  84. 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() {
@@ -618,15 +596,15 @@ export class Agent {
618
596
  }
619
597
  // Map the event to a candidate session key using the configured resolver
620
598
  // on a synthetic normalized-event shape. Default resolver buckets by
621
- // chatId; agents using a different sessionResolver (agreementId, taskId,
599
+ // addressId; agents using a different sessionResolver (agreementId, taskId,
622
600
  // counterpartyId) will get the right lane here as long as the wire
623
601
  // event carries the corresponding id.
624
602
  const fakeNormalized = {
625
- chatId: event.chatId,
603
+ addressId: event.addressId,
626
604
  event: { type: 'message', timestamp: Date.now(), text: '' },
627
605
  };
628
606
  let sessionId = this.sessionResolver(fakeNormalized, ownAgentId);
629
- // store claims deliver agreement wakes with no chatId. Do not
607
+ // store claims deliver agreement wakes with no addressId. Do not
630
608
  // drop them; lane by agreement the same way task bootstrap does
631
609
  // (`agrn-<agreementId>`), never a bare agreement id.
632
610
  if (!sessionId) {
@@ -654,10 +632,10 @@ export class Agent {
654
632
  return this.eventQueue.enqueue({
655
633
  type: SERVER_EVENTS.resourceChanged,
656
634
  resource: event,
657
- chatId: event.chatId,
635
+ addressId: event.addressId,
658
636
  taskId: event.taskId,
659
637
  agreementId,
660
- // Claimer/buyer — chat_send defaults receiverId from senderId.
638
+ // Claimer/buyer — the canonical reply command explicitly names the requester.
661
639
  ...(actorId ? { senderId: actorId } : {}),
662
640
  }, laneKey);
663
641
  }
@@ -671,15 +649,27 @@ export class Agent {
671
649
  ['proposal_approved', 'proposal_rejected'].includes(notice?.operation ?? '') &&
672
650
  isContextAccessRequest(notice?.agreement?.grantRequest))
673
651
  return;
674
- const messageId = (metadata.responsibilityId ?? metadata.messageId);
652
+ const messageId = (metadata.receiptId ?? metadata.messageId);
675
653
  if (messageId && this.seenMessages.has(messageId)) {
676
654
  runtimeLog.debug('Agent', `skip duplicate messageId=${messageId}`);
677
655
  return;
678
656
  }
679
657
  if (messageId)
680
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
+ };
681
669
  const ownAgentId = this._resolveOwnAgentId();
682
670
  if (!ownAgentId) {
671
+ if (roomless)
672
+ throw unroutable('ownAgentId is missing');
683
673
  runtimeLog.warn('Agent', 'handleMessage: ownAgentId missing — dropping event');
684
674
  return;
685
675
  }
@@ -700,7 +690,9 @@ export class Agent {
700
690
  const normalized = normalizeIncomingEvent({ text: enrichedText, metadata, ownAgentId });
701
691
  const sessionId = this.sessionResolver(normalized, ownAgentId);
702
692
  if (!sessionId) {
703
- 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'}`);
704
696
  return;
705
697
  }
706
698
  const executionContext = metadata.executionContext;
@@ -708,6 +700,8 @@ export class Agent {
708
700
  if (!this.sessions.has(laneKey)) {
709
701
  this.sessions.set(laneKey, { sessionId, ownAgentId, executionContext });
710
702
  }
703
+ if (roomlessAgreementId)
704
+ this.sessions.get(laneKey).roomlessAgreementId = roomlessAgreementId;
711
705
  // The inbox loop owns ack (act-before-ack, delivery law); the agent only
712
706
  // routes the event onto its lane. No live-path watermark ack — there is no
713
707
  // separate live/push channel to reconcile against anymore.
@@ -716,7 +710,7 @@ export class Agent {
716
710
  // the same message being processed twice; leaving it set on a failed wake
717
711
  // would make the redelivery this rejection buys us a no-op.
718
712
  try {
719
- await this.eventQueue.enqueue({ ...normalized.event, chatId: normalized.chatId }, laneKey);
713
+ await this.eventQueue.enqueue({ ...normalized.event, addressId: normalized.addressId }, laneKey);
720
714
  }
721
715
  catch (err) {
722
716
  if (messageId)
@@ -810,8 +804,7 @@ export class Agent {
810
804
  // Fail open: a lookup error must not drop a live hire's session.
811
805
  }
812
806
  }
813
- async laneAgreementIsTerminal(sessionId) {
814
- const agreementId = agreementIdFromLane(sessionId);
807
+ async agreementIsTerminal(agreementId) {
815
808
  if (!agreementId || !this.agreementService)
816
809
  return false;
817
810
  try {
@@ -835,8 +828,10 @@ export class Agent {
835
828
  * Every rule here reads a row or the frame itself. None of them reads a lane,
836
829
  * an agent kind, a name or an id shape.
837
830
  */
838
- async wakeSuppression(event, sessionId) {
839
- 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)) {
840
835
  return { reason: 'agreement_terminal', because: 'its agreement is terminal' };
841
836
  }
842
837
  if (this.isSettledTaskEcho(event)) {
@@ -877,7 +872,7 @@ export class Agent {
877
872
  *
878
873
  * A task frame that carries a terminal state AND a result is an echo of work
879
874
  * this agent has already finished: there is no decision left in it. Dropping it
880
- * is not the same suppression as {@link laneAgreementIsTerminal}, which waits
875
+ * is not the same suppression as {@link agreementIsTerminal}, which waits
881
876
  * for the agreement to end — this fires while the engagement is still live,
882
877
  * which is where the cost actually was.
883
878
  *
@@ -1046,17 +1041,74 @@ export class Agent {
1046
1041
  const tools = this.options.tools ?? this.options.services?.tools ?? [];
1047
1042
  return tools.some((t) => toolName(t) === 'connection_request');
1048
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
+ }
1049
1098
  async _processEvent(event, laneKey) {
1050
- 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());
1051
1103
  }
1052
1104
  async _processEventInContext(event, laneKey) {
1053
1105
  const session = this.sessions.get(laneKey);
1054
1106
  if (!session?.sessionId)
1055
1107
  return;
1056
1108
  if (session.executionContext)
1057
- await validateWorkExecution(session.executionContext, this.options.operatorKey, resolveBackendUrl());
1109
+ await validateWorkExecution(currentWorkExecution() ?? session.executionContext, this.options.operatorKey, resolveBackendUrl());
1058
1110
  // Presentation may call our sender an rpb-* room binding or the person we
1059
- // 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
1060
1112
  // an echo; another message from the same binding still gets a full wake.
1061
1113
  const incoming = event;
1062
1114
  const events = incoming?.type === 'batch' && Array.isArray(incoming.events) ? incoming.events : [event];
@@ -1067,7 +1119,7 @@ export class Agent {
1067
1119
  && e?.source === 'active-task-snapshot' && (task?.state === 'active' || task?.state === 'in-progress')
1068
1120
  && task?.assigneeId === this.options.agentId && task.taskId && task.updatedAt && this.confirmedPlanVersions.has(JSON.stringify([laneKey, JSON.stringify([task.taskId, task.updatedAt])])))
1069
1121
  return false;
1070
- 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);
1071
1123
  });
1072
1124
  if (remaining.length === 0) {
1073
1125
  this.recordWakeOutcome(session, { kind: 'no-op', reason: events.some(e => e?.type === 'task_result') ? 'own_plan_snapshot' : 'own_message_echo' });
@@ -1192,7 +1244,9 @@ export class Agent {
1192
1244
  // a zombie — burning tokens and racing the redelivered retry of the same
1193
1245
  // event (duplicate sends, runaway per-wake input).
1194
1246
  const tickAbort = new AbortController();
1195
- 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);
1196
1250
  const executionController = this.executionController(identity, tickAbort.signal, taskId => registerTaskRun(taskId));
1197
1251
  let heldTask;
1198
1252
  const pendingEffects = new Set();
@@ -1218,6 +1272,8 @@ export class Agent {
1218
1272
  taskRuns.clear();
1219
1273
  };
1220
1274
  const checkpoint = async (namedTaskId) => {
1275
+ if (session.executionContext)
1276
+ await validateWorkExecution(currentWorkExecution() ?? session.executionContext, this.options.operatorKey, resolveBackendUrl());
1221
1277
  if (tickAbort.signal.aborted) {
1222
1278
  if (tickAbort.signal.reason?.code === 'TASK_CONTROL')
1223
1279
  heldTask = tickAbort.signal.reason;
@@ -1276,11 +1332,9 @@ export class Agent {
1276
1332
  const sameRoom = 'sessionId' in effect && effect.sessionId === session.sessionId;
1277
1333
  const scopedEffect = sameRoom && effect.kind === 'read-context'
1278
1334
  ? { ...effect, opts: { ...effect.opts, workScopes: contextScopesFromWake(frame, session.sessionId) } }
1279
- : agreementId && effect.kind === 'send-message'
1280
- ? { ...effect, underAgreementId: effect.underAgreementId ?? agreementId }
1281
- : agreementId && (effect.kind === 'tool-call' || effect.kind === 'record-event')
1282
- ? { ...effect, serviceAgreementId: effect.serviceAgreementId ?? agreementId }
1283
- : effect;
1335
+ : agreementId && (effect.kind === 'tool-call' || effect.kind === 'record-event')
1336
+ ? { ...effect, serviceAgreementId: effect.serviceAgreementId ?? agreementId }
1337
+ : effect;
1284
1338
  const operation = this.eff(scopedEffect);
1285
1339
  pendingEffects.add(operation);
1286
1340
  const response = await operation.finally(() => { pendingEffects.delete(operation); });
@@ -1291,7 +1345,8 @@ export class Agent {
1291
1345
  };
1292
1346
  try {
1293
1347
  await checkpoint();
1294
- 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 } : {}) })));
1295
1350
  tickWork = executionController ? executionControllers.run(executionController, tick) : tick();
1296
1351
  const result = await Promise.race([
1297
1352
  tickWork,
@@ -1302,6 +1357,9 @@ export class Agent {
1302
1357
  }, checkout ? Math.max(this.tickTimeoutMs, CHECKOUT_TICK_TIMEOUT_MS) : this.tickTimeoutMs);
1303
1358
  }),
1304
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.
1305
1363
  session.ctx = result.ctx;
1306
1364
  this.carryExecutionState(session.ctx, executionController);
1307
1365
  session.state = result.state;
@@ -1410,8 +1468,8 @@ export class Agent {
1410
1468
  const ev = event;
1411
1469
  const senderType = String(ev?.senderType ?? '').toUpperCase();
1412
1470
  const senderId = ev?.senderId;
1413
- if (senderId && senderType === 'USER') {
1414
- 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(() => { });
1415
1473
  }
1416
1474
  // and then say so. Everything above is recovery for the lane —
1417
1475
  // it does not mean the event was handled, and the caller is about to
@@ -1508,13 +1566,15 @@ export class Agent {
1508
1566
  * Borrowing that path would make a dev tool able to disturb a live one.
1509
1567
  */
1510
1568
  async replayWake(record) {
1511
- 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());
1512
1572
  }
1513
1573
  async replayWakeInContext(record) {
1514
1574
  if (!record?.sessionId)
1515
1575
  throw new Error('replayWake: record.sessionId is required');
1516
1576
  if (record.executionContext)
1517
- await validateWorkExecution(record.executionContext, this.options.operatorKey, resolveBackendUrl());
1577
+ await validateWorkExecution(currentWorkExecution() ?? record.executionContext, this.options.operatorKey, resolveBackendUrl());
1518
1578
  const identity = {
1519
1579
  agentId: record.agentId || this.options.agentId,
1520
1580
  sessionId: record.sessionId,
@@ -1535,11 +1595,14 @@ export class Agent {
1535
1595
  // Same watchdog as a live wake, for the same reason: an unaborted Claude
1536
1596
  // query outlives the promise that abandoned it and keeps spending.
1537
1597
  const abort = new AbortController();
1538
- 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);
1539
1602
  const controller = this.executionController(identity, abort.signal);
1540
1603
  let watchdog;
1541
1604
  try {
1542
- const tick = () => this.wakeEffects.run(marks, () => this.brain.tick({
1605
+ const tick = () => this.wakeDestination.run(destination, () => this.wakeEffects.run(marks, () => this.brain.tick({
1543
1606
  event: record.event,
1544
1607
  ctx: record.ctx,
1545
1608
  state: record.state,
@@ -1548,7 +1611,7 @@ export class Agent {
1548
1611
  memory,
1549
1612
  transitionTap: this.options.transitionTap,
1550
1613
  signal: abort.signal,
1551
- }));
1614
+ })));
1552
1615
  const result = await Promise.race([
1553
1616
  controller ? executionControllers.run(controller, tick) : tick(),
1554
1617
  new Promise((_, reject) => {
@@ -1629,9 +1692,9 @@ export class Agent {
1629
1692
  // work delivered by the inbox's task channel, not by a chat
1630
1693
  // row — this is what reaches a task under a chat-less agreement or one
1631
1694
  // the agent assigned to itself.
1632
- onTask: async (task) => {
1695
+ onTask: async (task, inputs) => {
1633
1696
  try {
1634
- await this.wakeTaskAssignment(task.taskId, { fromInbox: true });
1697
+ await this.wakeTaskAssignment(task.taskId, { fromInbox: true, inputs });
1635
1698
  }
1636
1699
  catch (err) {
1637
1700
  runtimeLog.warn(this.options.name || 'agent', `inbox task ${task.taskId} could not be woken: ${err.message}`);
@@ -1659,13 +1722,18 @@ export class Agent {
1659
1722
  const laneKey = laneKeyFor(this.options.agentId, sessionId);
1660
1723
  const scope = this.wakeEffects.getStore()?.scope;
1661
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;
1662
1728
  this.wakeLedger.arm({
1663
1729
  laneKey,
1664
1730
  sessionId,
1665
1731
  firesAt: armed.firesAt,
1732
+ wakeId: armed.wakeId,
1666
1733
  reason,
1667
1734
  ...(scope ? { scope } : {}),
1668
- executionContext: currentWorkExecution(),
1735
+ ...(addressId ? { addressId } : {}),
1736
+ executionContext: currentWorkExecution() ? { ...currentWorkExecution(), attemptId: undefined } : undefined,
1669
1737
  });
1670
1738
  return { ...armed, durable: this.wakeLedger.durable };
1671
1739
  }
@@ -1693,10 +1761,10 @@ export class Agent {
1693
1761
  if (this.stateTimers.peek(wake.laneKey))
1694
1762
  continue;
1695
1763
  if (!this.sessions.has(wake.laneKey)) {
1696
- 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 } : {}) });
1697
1765
  }
1698
1766
  try {
1699
- 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);
1700
1768
  runtimeLog.info(this.options.name || 'agent', `re-armed a saved wake laneKey=${wake.laneKey} at=${new Date(wake.firesAt).toISOString()}`);
1701
1769
  }
1702
1770
  catch (err) {
@@ -1719,11 +1787,17 @@ export class Agent {
1719
1787
  }
1720
1788
  const needle = request.match.trim();
1721
1789
  const title = request.title?.trim() || needle;
1722
- await this.handleMessage(`New marketplace request matching your tags (${needle}): ${title}. Claim with agreement_claim if you will take it.`, {
1723
- messageId: `request-wake:${request.agreementId}:${request.ts}`,
1724
- chatId: `marketplace:${request.agreementId}`,
1725
- agreementId: request.agreementId,
1726
- });
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
+ }
1727
1801
  }
1728
1802
  /** replay task-assigned for an existing active task (demo recovery). */
1729
1803
  async wakeTaskAssignment(taskId, options) {
@@ -1736,21 +1810,23 @@ export class Agent {
1736
1810
  if (state !== 'active' && state !== 'in-progress') {
1737
1811
  return;
1738
1812
  }
1813
+ const selected = currentWorkExecution();
1739
1814
  const sessionId = laneSessionIdForTask(full);
1740
- const laneKey = laneKeyFor(agentId, sessionId);
1815
+ const laneKey = laneKeyFor(agentId, sessionId, selected?.contextId);
1741
1816
  if (!this.sessions.has(laneKey)) {
1742
1817
  this.sessions.set(laneKey, { sessionId, ownAgentId: agentId, executionContext: currentWorkExecution() });
1743
1818
  }
1744
1819
  await this.eventQueue.enqueue({
1745
1820
  type: 'task_result',
1746
- ...(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' } : {}),
1747
1823
  result: {
1748
1824
  ...full,
1825
+ ...(options?.inputs?.length ? { inputs: options.inputs } : {}),
1749
1826
  taskId: full.taskId ?? taskId,
1750
1827
  state,
1751
1828
  providerIsYou: true,
1752
1829
  },
1753
- chatId: sessionId,
1754
1830
  }, laneKey);
1755
1831
  }
1756
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`