@ziggs-ai/agent-sdk 0.22.0 → 0.24.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 (39) hide show
  1. package/dist/Agent.d.ts +8 -2
  2. package/dist/Agent.js +232 -53
  3. package/dist/brain/ClaudeBrain.d.ts +1 -8
  4. package/dist/brain/ClaudeBrain.js +13 -42
  5. package/dist/brain/ZiggsBrain.d.ts +1 -1
  6. package/dist/brain/ZiggsBrain.js +9 -2
  7. package/dist/brain/claudeCognition.js +34 -9
  8. package/dist/brain/claudeSdkProtocol.d.ts +1 -1
  9. package/dist/brain/claudeSdkProtocol.js +5 -2
  10. package/dist/brain/claudeSdkTools.d.ts +3 -0
  11. package/dist/brain/claudeSdkTools.js +10 -1
  12. package/dist/brain/index.d.ts +9 -1
  13. package/dist/brain/index.js +5 -1
  14. package/dist/brain/toolManagerToSdkMcp.js +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +1 -1
  17. package/dist/ingress/normalizeIncoming.d.ts +1 -0
  18. package/dist/ingress/normalizeIncoming.js +2 -1
  19. package/dist/platform/ZiggsEffectHandler.d.ts +1 -1
  20. package/dist/platform/ZiggsEffectHandler.js +20 -10
  21. package/dist/platform/inboxLoop.d.ts +4 -3
  22. package/dist/platform/inboxLoop.js +5 -6
  23. package/dist/platform/tasks/TaskService.d.ts +16 -1
  24. package/dist/platform/tasks/TaskService.js +22 -3
  25. package/dist/platform/tools/tier1/protocolRunner.js +11 -2
  26. package/dist/platform/tools/tier1/protocolTools.js +20 -5
  27. package/dist/probeChat.d.ts +12 -0
  28. package/dist/probeChat.js +14 -0
  29. package/dist/runtime/PromptBuilder.js +4 -1
  30. package/dist/runtime/agreementContext.d.ts +5 -0
  31. package/dist/runtime/agreementContext.js +75 -0
  32. package/dist/runtime/mandateDeclaration.d.ts +1 -1
  33. package/dist/runtime/mandateDeclaration.js +4 -4
  34. package/dist/runtime/runTurn.js +3 -2
  35. package/dist/runtime/wakeEffects.d.ts +5 -0
  36. package/dist/runtime/wakeEffects.js +14 -1
  37. package/dist/tools/capabilityAdapter.js +1 -0
  38. package/dist/types.d.ts +12 -0
  39. package/package.json +2 -2
package/dist/Agent.d.ts CHANGED
@@ -230,9 +230,13 @@ export declare class Agent {
230
230
  options: Required<Pick<AgentOptions, 'agentId' | 'operatorKey' | 'openaiKey'>> & AgentOptions;
231
231
  toolManager: ToolManager;
232
232
  llm?: OpenAIAdapter | LlmAdapter;
233
- brain: ZiggsBrain | ClaudeBrain;
233
+ brain: ZiggsBrain | ClaudeBrain | {
234
+ workflow: ZiggsBrain['workflow'];
235
+ tick: import('./brain/index.js').HandlerBrainConfig['tick'];
236
+ };
234
237
  private eff;
235
238
  private eventQueue;
239
+ private activeTaskRuns;
236
240
  private sessions;
237
241
  private sessionResolver;
238
242
  private tickTimeoutMs;
@@ -265,6 +269,9 @@ export declare class Agent {
265
269
  constructor(options?: AgentOptions);
266
270
  private _validate;
267
271
  private _setup;
272
+ /** Runtime control is a task fact, never model-interpreted message text. */
273
+ private taskExecutionControl;
274
+ private acknowledgeExecutionControl;
268
275
  /**
269
276
  * Handler for a resource change (artifact created, task state transition,
270
277
  * agreement updated) surfaced by the inbox loop. The agent decides which
@@ -305,7 +312,6 @@ export declare class Agent {
305
312
  * wake keys on the live room / hire.
306
313
  */
307
314
  private dropStaleCancelledAgreementSession;
308
- private formedByForHireActivation;
309
315
  private laneAgreementIsTerminal;
310
316
  /**
311
317
  * Whether the host answers this wake without spending a model turn.
package/dist/Agent.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { withAgreementContext } from './runtime/agreementContext.js';
1
2
  import { agreementLaneId, agreementIdFromLane } from '@ziggs-ai/api-client';
2
3
  import { createDeclaredPollRunner } from './platform/polls/declaredPollRunner.js';
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { AsyncLocalStorage } from 'node:async_hooks';
5
- import { WakeEffects } from './runtime/wakeEffects.js';
6
+ import { contextScopesFromWake, WakeEffects, workScopeFromWake } from './runtime/wakeEffects.js';
6
7
  export { workScopeFromWake } from './runtime/wakeEffects.js';
7
8
  import { ZiggsBrain } from './brain/ZiggsBrain.js';
8
9
  import { ClaudeBrain } from './brain/ClaudeBrain.js';
@@ -17,6 +18,7 @@ import { WAKE_TOOLS } from './platform/tools/tier1/wakeTools.js';
17
18
  import { createZiggsEffectHandler } from './platform/ZiggsEffectHandler.js';
18
19
  import { normalizeIncomingEvent } from './ingress/normalizeIncoming.js';
19
20
  import { PromptBuilder } from './runtime/PromptBuilder.js';
21
+ import { removeTaskBook } from './runtime/taskBookkeeping.js';
20
22
  import { InMemoryStore, hireIdsFromEvent, memorySectionForWake, partitionMemoryStore, uniqueHireIds, } from './memory/MemoryStore.js';
21
23
  import { ToolManager } from './tools/ToolManager.js';
22
24
  import { toolName } from './tools/defineTool.js';
@@ -133,7 +135,7 @@ function createBackendServices(options) {
133
135
  // the provider client throws at construction on a missing key, so a claude-sdk
134
136
  // agent left on the default (OpenAI) model with no openaiKey would fail to
135
137
  // start over an adapter it never uses. Skip it.
136
- const llm = isClaudeCognition(options.cognition)
138
+ const llm = options.brain?.kind === 'handler' || isClaudeCognition(options.cognition)
137
139
  ? undefined
138
140
  : isLlmAdapter(options.services?.llm)
139
141
  ? options.services.llm
@@ -179,6 +181,8 @@ export class Agent {
179
181
  brain;
180
182
  eff;
181
183
  eventQueue;
184
+ // In-process ticks only; durable intent remains on the existing task row.
185
+ activeTaskRuns = new Map();
182
186
  sessions = new Map();
183
187
  sessionResolver;
184
188
  tickTimeoutMs;
@@ -269,7 +273,10 @@ export class Agent {
269
273
  this._setup();
270
274
  }
271
275
  _validate() {
272
- if (isClaudeCognition(this.options.cognition)) {
276
+ if (this.options.brain?.kind === 'handler') {
277
+ // Deterministic handlers use no model adapter or credentials.
278
+ }
279
+ else if (isClaudeCognition(this.options.cognition)) {
273
280
  if (!this.options.anthropicKey) {
274
281
  throw new Error('anthropicKey is required for the Claude brain — pass claudeBrain({ anthropicKey }) or set ANTHROPIC_API_KEY');
275
282
  }
@@ -301,7 +308,7 @@ export class Agent {
301
308
  operatorKey: this.options.operatorKey,
302
309
  agentId: this.options.agentId,
303
310
  };
304
- const messageSender = async (message, receiverId, sessionId, messageId) => {
311
+ const messageSender = async (message, receiverId, sessionId, messageId, conversationAgreementId) => {
305
312
  const sentId = messageId ?? `sdk_${randomUUID()}`;
306
313
  const result = await sendChatMessage({
307
314
  chatId: sessionId,
@@ -314,6 +321,7 @@ export class Agent {
314
321
  // pass a wake-derived id for replay-stability; fall back to a fresh id
315
322
  // only when absent (matches the old WS path letting the server assign).
316
323
  messageId: sentId,
324
+ conversationAgreementId,
317
325
  }, creds);
318
326
  if (!result.success)
319
327
  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.');
@@ -340,7 +348,7 @@ export class Agent {
340
348
  // The Claude brain has no FSM — it drives query() per wake and parks. It
341
349
  // still plugs into the agent's lane/timer machinery via a trivial parked
342
350
  // workflow, synthesized here so callers don't hand-write a dummy one.
343
- if (isClaudeCognition(this.options.cognition)) {
351
+ if (this.options.brain?.kind === 'handler' || isClaudeCognition(this.options.cognition)) {
344
352
  workflow = {
345
353
  id: this.options.agentId,
346
354
  description: this.options.description,
@@ -378,29 +386,31 @@ export class Agent {
378
386
  armWakeAt: (sessionId, at, reason) => this.stateTimers.armWakeAt(laneKeyFor(this.options.agentId, sessionId), at, reason, this.wakeEffects.getStore()?.scope),
379
387
  },
380
388
  });
381
- this.brain = isClaudeCognition(this.options.cognition)
382
- ? new ClaudeBrain({
383
- workflow,
384
- toolManager: services.toolManager,
385
- description: this.options.description,
386
- systemPrompt: this.options.systemPrompt,
387
- specialization: this.options.specialization,
388
- model: resolveClaudeModel(this.options.model, this.options.services?.llm?.model),
389
- anthropicKey: this.options.anthropicKey,
390
- operatorKey: this.options.operatorKey,
391
- agentId: this.options.agentId,
392
- // No taskService/agreementService: Claude-brain tools execute through
393
- // eff({ kind: 'tool-call' }) → the shared executor, which sources those
394
- // services from the effect handler's deps like the FSM path.
395
- maxTurns: this.options.claudeMaxTurns,
396
- statelessPerWake: this.options.claudeStatelessPerWake,
397
- actsOnHireActivation: this.options.actsOnHireActivation === true,
398
- })
399
- : new ZiggsBrain({
400
- workflow,
401
- toolManager: services.toolManager,
402
- promptBuilder: services.promptBuilder,
403
- });
389
+ this.brain = this.options.brain?.kind === 'handler'
390
+ ? { workflow, tick: this.options.brain.config.tick }
391
+ : isClaudeCognition(this.options.cognition)
392
+ ? new ClaudeBrain({
393
+ workflow,
394
+ toolManager: services.toolManager,
395
+ description: this.options.description,
396
+ systemPrompt: this.options.systemPrompt,
397
+ specialization: this.options.specialization,
398
+ model: resolveClaudeModel(this.options.model, this.options.services?.llm?.model),
399
+ anthropicKey: this.options.anthropicKey,
400
+ operatorKey: this.options.operatorKey,
401
+ agentId: this.options.agentId,
402
+ // No taskService/agreementService: Claude-brain tools execute through
403
+ // eff({ kind: 'tool-call' }) → the shared executor, which sources those
404
+ // services from the effect handler's deps like the FSM path.
405
+ maxTurns: this.options.claudeMaxTurns,
406
+ statelessPerWake: this.options.claudeStatelessPerWake,
407
+ actsOnHireActivation: this.options.actsOnHireActivation === true,
408
+ })
409
+ : new ZiggsBrain({
410
+ workflow,
411
+ toolManager: services.toolManager,
412
+ promptBuilder: services.promptBuilder,
413
+ });
404
414
  this.eventQueue = new EventQueue((event, laneKey) => this._processEvent(event, laneKey));
405
415
  // A timer frame is not a delivery: there is no watermark waiting on it and
406
416
  // nothing to redeliver, so its failure is logged and dropped here rather
@@ -411,6 +421,49 @@ export class Agent {
411
421
  });
412
422
  });
413
423
  }
424
+ /** Runtime control is a task fact, never model-interpreted message text. */
425
+ async taskExecutionControl(taskId) {
426
+ if (!this.taskService)
427
+ return null;
428
+ let task;
429
+ try {
430
+ task = await this.taskService.getTask(taskId);
431
+ }
432
+ catch (error) {
433
+ if ([403, 404].includes(error.status ?? 0))
434
+ return { blocked: true };
435
+ throw error;
436
+ }
437
+ // A coordinator sees its children's task events too; those do not pause it.
438
+ if (task.executorIsYou !== true && task.assigneeId !== this.options.agentId)
439
+ return null;
440
+ return { blocked: task.state === 'cancelled' || (task.state === 'active' && task.control?.action === 'pause'), closed: task.state === 'cancelled', control: task.control };
441
+ }
442
+ async acknowledgeExecutionControl(taskId, control) {
443
+ if (control?.status !== 'requested')
444
+ return;
445
+ try {
446
+ if (control.action !== 'resume') {
447
+ // Custom session resolvers may run the same task in different lanes.
448
+ // Acknowledgement covers all of this runtime's task executions.
449
+ const active = [...(this.activeTaskRuns.get(taskId) ?? [])];
450
+ for (const run of active) {
451
+ run.control = control;
452
+ run.abort.abort(Object.assign(new Error('Task execution held'), { code: 'TASK_CONTROL', taskId, control }));
453
+ }
454
+ // The finishing run acknowledges after its actual operations settle.
455
+ // A tool that ignores cancellation must not hold up this inbox lane.
456
+ if (active.length)
457
+ return;
458
+ }
459
+ await this.taskService?.acknowledgeControl(taskId, control.requestId);
460
+ }
461
+ catch (error) {
462
+ // A newer user request wins. Its durable inbox event will be handled next.
463
+ if (error.status !== 409)
464
+ runtimeLog.warn('Agent', `control acknowledgement failed for ${taskId}: ${error.message}`);
465
+ }
466
+ }
414
467
  /**
415
468
  * Handler for a resource change (artifact created, task state transition,
416
469
  * agreement updated) surfaced by the inbox loop. The agent decides which
@@ -612,24 +665,6 @@ export class Agent {
612
665
  // Fail open: a lookup error must not drop a live hire's session.
613
666
  }
614
667
  }
615
- async formedByForHireActivation(event) {
616
- const fromWake = formedByFromWake(event);
617
- if (fromWake)
618
- return fromWake;
619
- const agreementId = agreementIdFromWake(event);
620
- if (!agreementId || !this.agreementService)
621
- return undefined;
622
- try {
623
- const agreement = await this.agreementService.get(agreementId);
624
- const formedBy = agreement?.formedBy;
625
- if (formedBy === 'agent' || formedBy === 'principal')
626
- return formedBy;
627
- }
628
- catch {
629
- // Fail open: a lookup error must not drop a person's store-claim wake.
630
- }
631
- return undefined;
632
- }
633
668
  async laneAgreementIsTerminal(sessionId) {
634
669
  const agreementId = agreementIdFromLane(sessionId);
635
670
  if (!agreementId || !this.agreementService)
@@ -666,7 +701,13 @@ export class Agent {
666
701
  };
667
702
  }
668
703
  if (isHireActivationNotice(event)) {
669
- const formedBy = await this.formedByForHireActivation(event);
704
+ const id = agreementIdFromWake(event);
705
+ const agreement = id && this.options.actsOnHireActivation !== true
706
+ ? await this.agreementService.get(id).catch(() => null)
707
+ : null;
708
+ if (agreement?.terms?.interactionMode === 'tasks')
709
+ return { reason: 'task_only_activation', because: 'task-only service waits for assigned work' };
710
+ const formedBy = formedByFromWake(event) ?? agreement?.formedBy;
670
711
  if (shouldSkipAgentFormedHireActivation({
671
712
  event,
672
713
  formedBy,
@@ -894,9 +935,10 @@ export class Agent {
894
935
  return;
895
936
  // Stale timer frames (superseded epoch) are dropped BEFORE touching the
896
937
  // live timer — a pure-stale frame must not reset a freshly armed countdown.
897
- const frame = this.stateTimers.stripStale(event, laneKey);
938
+ let frame = this.stateTimers.stripStale(event, laneKey);
898
939
  if (frame === null)
899
940
  return;
941
+ frame = await withAgreementContext(frame, id => this.agreementService.get(id));
900
942
  // Traffic resets inactivity timers, but must not cancel an appointment
901
943
  // the agent explicitly requested. Only its own fresh timer frame consumes
902
944
  // that wake; a notice-only tick cannot ask the model to restore it.
@@ -921,7 +963,48 @@ export class Agent {
921
963
  sessionId: session.sessionId,
922
964
  laneKey,
923
965
  };
966
+ // Cache only within this wake. Long turns refresh at action boundaries at
967
+ // most once per second, without a read per tool or a fallible final read.
968
+ const controls = new Map();
969
+ const readControl = async (taskId) => {
970
+ const cached = controls.get(taskId);
971
+ if (cached && Date.now() - cached.at < 1000)
972
+ return cached.value;
973
+ const value = await this.taskExecutionControl(taskId);
974
+ controls.set(taskId, { at: Date.now(), value });
975
+ return value;
976
+ };
977
+ const frames = frame?.type === 'batch'
978
+ ? frame.events : [frame];
979
+ const runnable = [];
980
+ for (const item of frames) {
981
+ const taskId = workScopeFromWake(item, session.sessionId).taskId;
982
+ const execution = taskId ? await readControl(taskId) : null;
983
+ if (taskId && execution?.blocked) {
984
+ // The brain never sees this frame, so close the books here the way a
985
+ // task-closed outcome would; a paused task is still open work.
986
+ if (session.ctx && execution.closed)
987
+ removeTaskBook(session.ctx, taskId);
988
+ if (session.ctx?.activeTaskId === taskId)
989
+ session.ctx = { ...session.ctx, activeTaskId: null };
990
+ if (this.sessionStore)
991
+ await this.sessionStore.save(laneKey, { ctx: session.ctx, state: session.state });
992
+ await this.acknowledgeExecutionControl(taskId, execution.control);
993
+ }
994
+ else {
995
+ if (taskId && execution?.control?.action === 'resume')
996
+ await this.acknowledgeExecutionControl(taskId, execution.control);
997
+ runnable.push(item);
998
+ }
999
+ }
1000
+ if (!runnable.length) {
1001
+ this.recordWakeOutcome(session, { kind: 'no-op', reason: 'task_control' });
1002
+ return;
1003
+ }
1004
+ if (runnable.length !== frames.length)
1005
+ frame = runnable.length === 1 ? runnable[0] : { type: 'batch', events: runnable };
924
1006
  const memory = this.memoryForWake(session, frame);
1007
+ const wakeTaskId = workScopeFromWake(frame, session.sessionId).taskId;
925
1008
  // Recorded here rather than after the tick: this is the last point where the
926
1009
  // input is still exactly what the brain will see, and a wake that goes on to
927
1010
  // throw is the one most worth replaying.
@@ -950,16 +1033,81 @@ export class Agent {
950
1033
  // event (duplicate sends, runaway per-wake input).
951
1034
  const tickAbort = new AbortController();
952
1035
  const marks = WakeEffects.forWake(frame, session.sessionId);
1036
+ let heldTask;
1037
+ const pendingEffects = new Set();
1038
+ let savedSession;
1039
+ let tickWork;
1040
+ const taskRuns = new Map();
1041
+ const registerTaskRun = (taskId) => {
1042
+ if (taskRuns.has(taskId))
1043
+ return;
1044
+ const run = { abort: tickAbort };
1045
+ const active = this.activeTaskRuns.get(taskId) ?? new Set();
1046
+ active.add(run);
1047
+ this.activeTaskRuns.set(taskId, active);
1048
+ taskRuns.set(taskId, { run, release: () => {
1049
+ active.delete(run);
1050
+ if (!active.size)
1051
+ this.activeTaskRuns.delete(taskId);
1052
+ } });
1053
+ };
1054
+ const releaseTaskRuns = () => {
1055
+ for (const { release } of taskRuns.values())
1056
+ release();
1057
+ taskRuns.clear();
1058
+ };
1059
+ const checkpoint = async (effectTaskId) => {
1060
+ if (tickAbort.signal.aborted) {
1061
+ if (tickAbort.signal.reason?.code === 'TASK_CONTROL')
1062
+ heldTask = tickAbort.signal.reason;
1063
+ throw tickAbort.signal.reason;
1064
+ }
1065
+ if (heldTask)
1066
+ throw new Error('Task execution held');
1067
+ const taskId = effectTaskId ?? marks.scope.taskId ?? wakeTaskId;
1068
+ if (!taskId)
1069
+ return;
1070
+ registerTaskRun(taskId);
1071
+ const execution = await readControl(taskId);
1072
+ if (heldTask)
1073
+ throw new Error('Task execution held');
1074
+ if (execution?.blocked) {
1075
+ heldTask = { taskId, control: execution.control };
1076
+ taskRuns.get(taskId).run.control = execution.control;
1077
+ tickAbort.abort(Object.assign(new Error('Task execution held'), { code: 'TASK_CONTROL', taskId, control: execution.control }));
1078
+ throw new Error('Task execution held');
1079
+ }
1080
+ };
953
1081
  const eff = async (effect) => {
954
- const response = await this.eff(effect);
1082
+ // Record-only telemetry can finish after interruption. Every action and
1083
+ // model call checks the current task before starting another operation.
1084
+ if (effect.kind !== 'record-event') {
1085
+ const args = effect.args;
1086
+ const taskId = effect.kind === 'tool-call' && typeof args?.taskId === 'string' ? args.taskId : undefined;
1087
+ await checkpoint(taskId);
1088
+ }
1089
+ const agreementId = marks.scope.agreementId;
1090
+ const sameRoom = 'sessionId' in effect && effect.sessionId === session.sessionId;
1091
+ const scopedEffect = sameRoom && effect.kind === 'read-context'
1092
+ ? { ...effect, opts: { ...effect.opts, workScopes: contextScopesFromWake(frame, session.sessionId) } }
1093
+ : sameRoom && agreementId && effect.kind === 'send-message'
1094
+ ? { ...effect, conversationAgreementId: effect.conversationAgreementId ?? agreementId }
1095
+ : sameRoom && agreementId && (effect.kind === 'tool-call' || effect.kind === 'record-event')
1096
+ ? { ...effect, serviceAgreementId: effect.serviceAgreementId ?? agreementId }
1097
+ : effect;
1098
+ const operation = this.eff(scopedEffect);
1099
+ pendingEffects.add(operation);
1100
+ const response = await operation.finally(() => { pendingEffects.delete(operation); });
955
1101
  marks.observe(effect, response);
956
1102
  for (const key of marks.sentMessageKeys)
957
1103
  this.outboundMessages.mark(key);
958
1104
  return response;
959
1105
  };
960
1106
  try {
1107
+ await checkpoint();
1108
+ tickWork = 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 }));
961
1109
  const result = await Promise.race([
962
- 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 })),
1110
+ tickWork,
963
1111
  new Promise((_, reject) => {
964
1112
  tickWatchdog = setTimeout(() => {
965
1113
  tickAbort.abort(new Error('tick timeout'));
@@ -1006,12 +1154,19 @@ export class Agent {
1006
1154
  }
1007
1155
  }
1008
1156
  if (this.sessionStore) {
1009
- this.sessionStore.save(laneKey, { ctx: session.ctx, state: session.state }).catch((err) => {
1157
+ savedSession = this.sessionStore.save(laneKey, { ctx: session.ctx, state: session.state });
1158
+ void savedSession.catch((err) => {
1010
1159
  runtimeLog.warn('Agent', `sessionStore.save failed laneKey=${laneKey}: ${err?.message || err}`);
1011
1160
  });
1012
1161
  }
1013
1162
  // Arm only when the timeout was handled: an unhandled state-timeout must
1014
1163
  // not re-arm, or the lane would runtimeLog.error every timeoutMs forever.
1164
+ if (tickAbort.signal.reason?.code === 'TASK_CONTROL')
1165
+ heldTask = tickAbort.signal.reason;
1166
+ if (heldTask) {
1167
+ this.recordWakeOutcome(session, { kind: 'no-op', reason: 'task_control' });
1168
+ return;
1169
+ }
1015
1170
  if (!result.stateTimeoutUnhandled)
1016
1171
  this._armIfTimedParked(laneKey, result.state);
1017
1172
  /**
@@ -1040,6 +1195,12 @@ export class Agent {
1040
1195
  }
1041
1196
  }
1042
1197
  catch (err) {
1198
+ if (tickAbort.signal.reason?.code === 'TASK_CONTROL')
1199
+ heldTask = tickAbort.signal.reason;
1200
+ if (heldTask) {
1201
+ this.recordWakeOutcome(session, { kind: 'no-op', reason: 'task_control' });
1202
+ return;
1203
+ }
1043
1204
  this.recordWakeOutcome(session, {
1044
1205
  kind: 'no-op',
1045
1206
  reason: 'tick_failed',
@@ -1069,9 +1230,27 @@ export class Agent {
1069
1230
  }
1070
1231
  finally {
1071
1232
  clearTimeout(tickWatchdog);
1233
+ // Do not extend the tick watchdog with unbounded cleanup. Keep the
1234
+ // run registered until its actual work settles; only then can we attest.
1235
+ const finish = async () => {
1236
+ await Promise.allSettled([tickWork, ...pendingEffects]);
1237
+ try {
1238
+ await savedSession;
1239
+ }
1240
+ catch {
1241
+ releaseTaskRuns();
1242
+ return;
1243
+ }
1244
+ const controlsToAck = [...taskRuns].map(([taskId, { run }]) => ({ taskId, control: run.control }));
1245
+ releaseTaskRuns();
1246
+ for (const { taskId, control } of controlsToAck)
1247
+ await this.acknowledgeExecutionControl(taskId, control);
1248
+ };
1249
+ void finish().catch(error => runtimeLog.warn('Agent', `task control cleanup failed: ${String(error)}`));
1072
1250
  // Also on the failure path: a tick that threw still must not leave the
1073
1251
  // work it was refused for sitting active.
1074
- await this.settleUnmetAfterAsk(laneKey).catch(() => { });
1252
+ if (!heldTask)
1253
+ await this.settleUnmetAfterAsk(laneKey).catch(() => { });
1075
1254
  }
1076
1255
  }
1077
1256
  /**
@@ -1250,7 +1429,7 @@ export class Agent {
1250
1429
  const full = (await this.taskService.getTask(taskId));
1251
1430
  const state = String(full.state ?? '');
1252
1431
  if (state !== 'active' && state !== 'in-progress') {
1253
- throw new Error(`wakeTaskAssignment: task ${taskId} is ${state}, not active`);
1432
+ return;
1254
1433
  }
1255
1434
  const sessionId = laneSessionIdForTask(full);
1256
1435
  const laneKey = laneKeyFor(agentId, sessionId);
@@ -4,6 +4,7 @@ import type { Workflow } from '../types.js';
4
4
  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
+ export { isPublishProbeChat, isCapabilityProbeSession } from '../probeChat.js';
7
8
  /**
8
9
  * status notices must not spend a Claude turn answering
9
10
  * nobody. Real work wakes arrive as chat `message`, synthetic `task_result`
@@ -22,12 +23,6 @@ import type { TickInput, TickOutput } from './ZiggsBrain.js';
22
23
  * message still lands in chat history and arrives as context on the next
23
24
  * real wake — skipped here is the LLM turn, not the information.
24
25
  */
25
- /**
26
- * Store publish chats the agent in a room named `probe_<agentId>_<ts>`.
27
- * That check is "are you connected and can you reply", not a hired job —
28
- * spending a model turn on it is how a long skill prompt 504s the gate.
29
- */
30
- export declare function isCapabilityProbeSession(sessionId: string | undefined | null): boolean;
31
26
  export declare function isNoticeOnlyWake(event: RawEvent | undefined, ownAgentId?: string | null): boolean;
32
27
  export type CognitionKind = 'fsm' | 'claude-sdk';
33
28
  export interface ClaudeBrainOptions {
@@ -88,8 +83,6 @@ export declare class ClaudeBrain implements Brain {
88
83
  workflow: Workflow;
89
84
  toolManager: ToolManager;
90
85
  private rolePrompt;
91
- /** Store one-liner — used when the publish probe asks who we are. */
92
- private listingBlurb;
93
86
  private specialization;
94
87
  private model;
95
88
  private anthropicKey;
@@ -1,4 +1,5 @@
1
1
  import { getAgreement } from '@ziggs-ai/api-client';
2
+ import { permitsAutomaticMandate } from '../runtime/agreementContext.js';
2
3
  import { query } from '@anthropic-ai/claude-agent-sdk';
3
4
  import { isMessageAddressedElsewhere, outcomeFromEvent } from '../runtime/buildOutcome.js';
4
5
  import { getBatchEvents } from '../context/batch.js';
@@ -12,6 +13,8 @@ import { runtimeLog } from '../shared/runtimeLog.js';
12
13
  import { resolveOnBehalfAgreementId, } from './onBehalfAgreement.js';
13
14
  import { filterStandingOfferNotices, isReadableAgreementNoticeLane } from './standingOfferNotice.js';
14
15
  import { formedByFromWake, shouldSkipAgentFormedHireActivation, } from './hireActivationNotice.js';
16
+ import { isPublishProbeChat } from '../probeChat.js';
17
+ export { isPublishProbeChat, isCapabilityProbeSession } from '../probeChat.js';
15
18
  /**
16
19
  * status notices must not spend a Claude turn answering
17
20
  * nobody. Real work wakes arrive as chat `message`, synthetic `task_result`
@@ -30,14 +33,6 @@ import { formedByFromWake, shouldSkipAgentFormedHireActivation, } from './hireAc
30
33
  * message still lands in chat history and arrives as context on the next
31
34
  * real wake — skipped here is the LLM turn, not the information.
32
35
  */
33
- /**
34
- * Store publish chats the agent in a room named `probe_<agentId>_<ts>`.
35
- * That check is "are you connected and can you reply", not a hired job —
36
- * spending a model turn on it is how a long skill prompt 504s the gate.
37
- */
38
- export function isCapabilityProbeSession(sessionId) {
39
- return typeof sessionId === 'string' && sessionId.startsWith('probe_');
40
- }
41
36
  export function isNoticeOnlyWake(event, ownAgentId) {
42
37
  const events = getBatchEvents(event);
43
38
  if (events.length === 0)
@@ -186,8 +181,6 @@ export class ClaudeBrain {
186
181
  workflow;
187
182
  toolManager;
188
183
  rolePrompt;
189
- /** Store one-liner — used when the publish probe asks who we are. */
190
- listingBlurb;
191
184
  specialization;
192
185
  model;
193
186
  anthropicKey;
@@ -220,7 +213,6 @@ export class ClaudeBrain {
220
213
  // written before the split still carry the prompt in `description`, so fall
221
214
  // back to it rather than silently dropping their role.
222
215
  this.rolePrompt = opts.systemPrompt || opts.description;
223
- this.listingBlurb = opts.description;
224
216
  this.specialization = opts.specialization ?? null;
225
217
  this.model = opts.model;
226
218
  this.anthropicKey = opts.anthropicKey;
@@ -279,36 +271,6 @@ export class ClaudeBrain {
279
271
  metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
280
272
  };
281
273
  }
282
- // Store publish must hear a reply even when leftover mail already tripped
283
- // the wake-rate breaker. The breaker used to sit above this and park the
284
- // probe with no send-message — Register then died on ALB 504.
285
- if (nextCtx.lastOutcome?.kind === 'message-received' &&
286
- isCapabilityProbeSession(identity.sessionId)) {
287
- const text = `I received your message. ${this.listingBlurb.trim().split(/\r?\n/)[0]?.trim() || 'I am online.'}`;
288
- const writeKeys = createDeliveryWriteKeys(identity.laneKey, rawEvent);
289
- const senderId = rawEvent?.senderId != null && String(rawEvent.senderId).trim() !== ''
290
- ? String(rawEvent.senderId)
291
- : undefined;
292
- await eff({
293
- kind: 'send-message',
294
- sessionId: identity.sessionId,
295
- ...(senderId ? { receiverId: senderId } : {}),
296
- text,
297
- messageId: writeKeys.messageKey({
298
- text,
299
- sessionId: identity.sessionId,
300
- receiverId: senderId,
301
- }),
302
- });
303
- runtimeLog.info('ClaudeBrain', `capability-probe reply — skipping LLM session=${identity.sessionId} agent=${this.agentId}`);
304
- return {
305
- ctx: nextCtx,
306
- state: state ?? DEFAULT_STATE,
307
- parked: true,
308
- stateTimeoutUnhandled: false,
309
- metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
310
- };
311
- }
312
274
  const listingCheck = await filterStandingOfferNotices(rawEvent, this.agentId, readAgreement);
313
275
  if (listingCheck.ignoredAgreementIds.length > 0) {
314
276
  rawEvent = listingCheck.event;
@@ -328,7 +290,11 @@ export class ClaudeBrain {
328
290
  }
329
291
  const now = Date.now();
330
292
  const recentDeliveries = (nextCtx.recentDeliveries ?? []).filter((t) => now - t < WAKE_WINDOW_MS);
331
- if (recentDeliveries.length >= MAX_WAKES_PER_WINDOW) {
293
+ // Store publish still has to hear a model reply when leftover mail already
294
+ // tripped the breaker. The prefix only skips the park — it does not write
295
+ // a canned answer.
296
+ if (recentDeliveries.length >= MAX_WAKES_PER_WINDOW &&
297
+ !isPublishProbeChat(identity.sessionId)) {
332
298
  runtimeLog.warn('ClaudeBrain', `wake-rate breaker tripped: ${recentDeliveries.length} wakes in ${WAKE_WINDOW_MS / 60_000}min ` +
333
299
  `session=${identity.sessionId} agent=${this.agentId} — skipping LLM for this wake`);
334
300
  nextCtx.recentDeliveries = recentDeliveries;
@@ -409,6 +375,7 @@ export class ClaudeBrain {
409
375
  // not just task_result events — see resolveOnBehalfAgreementId.
410
376
  // Skip payer-only subs named on the worker result; bind to the
411
377
  // provider hire (see onBehalfAgreement.ts).
378
+ allowAutomaticMandate: permitsAutomaticMandate(rawEvent),
412
379
  onBehalfOfAgreementId: resolveOnBehalfAgreementId(rawEvent, Array.isArray(contextSnap?.agreements)
413
380
  ? contextSnap.agreements
414
381
  : null, this.agentId, identity.sessionId),
@@ -602,6 +569,10 @@ export class ClaudeBrain {
602
569
  await consumeQuery(resumeId);
603
570
  }
604
571
  catch (err) {
572
+ if (signal?.aborted && signal.reason?.code === 'TASK_CONTROL') {
573
+ nextCtx.claudeSessionId = this.statelessPerWake ? undefined : sessionId;
574
+ return { ctx: nextCtx, state: state ?? DEFAULT_STATE, parked: true, stateTimeoutUnhandled: false, metrics };
575
+ }
605
576
  // Turn exhaustion arrives as a thrown error result, not a break, so it
606
577
  // needs its own note before the throw propagates.
607
578
  if (isMaxTurnsError(err)) {
@@ -88,5 +88,5 @@ export declare class ZiggsBrain implements Brain {
88
88
  toolManager: ToolManager;
89
89
  promptBuilder: PromptBuilder;
90
90
  constructor({ workflow, toolManager, promptBuilder }: ZiggsBrainOptions);
91
- tick({ event, ctx, state, identity, eff, memory, transitionTap }: TickInput): Promise<TickOutput>;
91
+ tick({ event, ctx, state, identity, eff, memory, transitionTap, signal }: TickInput): Promise<TickOutput>;
92
92
  }
@@ -31,7 +31,7 @@ export class ZiggsBrain {
31
31
  this.toolManager = toolManager;
32
32
  this.promptBuilder = promptBuilder || new PromptBuilder();
33
33
  }
34
- async tick({ event, ctx, state, identity, eff, memory, transitionTap }) {
34
+ async tick({ event, ctx, state, identity, eff, memory, transitionTap, signal }) {
35
35
  if (!identity?.sessionId)
36
36
  throw new Error('ZiggsBrain.tick: identity.sessionId is required');
37
37
  if (!eff)
@@ -51,7 +51,14 @@ export class ZiggsBrain {
51
51
  });
52
52
  // `send` awaits the recursive _step until the machine settles at a
53
53
  // parked state (or stops). When it returns, we have the final state.
54
- await machine.send({ event: event, identity });
54
+ try {
55
+ await machine.send({ event: event, identity });
56
+ }
57
+ catch (error) {
58
+ if (!signal?.aborted || signal.reason?.code !== 'TASK_CONTROL')
59
+ throw error;
60
+ // Preserve completed FSM progress so Resume does not restart the task.
61
+ }
55
62
  return {
56
63
  ctx: machine.ctx,
57
64
  state: machine.state,