@ziggs-ai/agent-sdk 0.41.1 → 0.41.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -32,6 +32,8 @@
32
32
 
33
33
  ## Unreleased
34
34
 
35
+ ## 0.41.2
36
+
35
37
  ## 0.41.1
36
38
 
37
39
  - Publishing names a third audience, `company`: every team in your active organization's company. A listing posted to it counts as a listing when the agent reads its wakes. Built on `@ziggs-ai/api-client` 0.37.26.
package/dist/Agent.d.ts CHANGED
@@ -258,6 +258,8 @@ export declare class Agent {
258
258
  private telemetryClient?;
259
259
  private seenMessages;
260
260
  private stateTimers;
261
+ /** The agent's own check-backs, kept so a restart re-arms them. */
262
+ private wakeLedger;
261
263
  /** The per-agent inbox long-poll loop — this agent's sole wake source. */
262
264
  private inboxLoop;
263
265
  private inboxLoopEnabled;
@@ -435,6 +437,18 @@ export declare class Agent {
435
437
  private _armIfTimedParked;
436
438
  private _resolveOwnAgentId;
437
439
  connectAsync(_timeout?: number): Promise<this>;
440
+ /**
441
+ * Arm the lane's timer for the agent's own `wake_at`, and save it so a
442
+ * restart arms it again.
443
+ */
444
+ private armOwnWake;
445
+ /**
446
+ * Arm again the check-backs this agent asked for before the process went
447
+ * down. A wake whose time passed in the meantime fires on the next turn,
448
+ * once. The lane gets a session first, since a timer frame for a lane with
449
+ * no session is dropped.
450
+ */
451
+ private restoreWakes;
438
452
  /**
439
453
  * plain string compare against {@link AgentOptions.requestMatchTags}
440
454
  * and `specialization`. No match → return (caller acks). Match → normal wake.
package/dist/Agent.js CHANGED
@@ -20,6 +20,7 @@ import { contextReadTool, openTool, accessExplainTool } from './platform/tools/t
20
20
  import { EventQueue } from './platform/EventQueue.js';
21
21
  import { StateTimers } from './platform/StateTimers.js';
22
22
  import { laneKeyFor } from './platform/laneKey.js';
23
+ import { WakeLedger } from './platform/WakeLedger.js';
23
24
  import { WAKE_TOOLS } from './platform/tools/tier1/wakeTools.js';
24
25
  import { createZiggsEffectHandler } from './platform/ZiggsEffectHandler.js';
25
26
  import { normalizeIncomingEvent } from './ingress/normalizeIncoming.js';
@@ -222,6 +223,8 @@ export class Agent {
222
223
  telemetryClient;
223
224
  seenMessages = new SeenMessages();
224
225
  stateTimers;
226
+ /** The agent's own check-backs, kept so a restart re-arms them. */
227
+ wakeLedger;
225
228
  /** The per-agent inbox long-poll loop — this agent's sole wake source. */
226
229
  inboxLoop = null;
227
230
  inboxLoopEnabled;
@@ -304,6 +307,7 @@ export class Agent {
304
307
  operatorKey: options.operatorKey,
305
308
  agentId: options.agentId,
306
309
  });
310
+ this.wakeLedger = new WakeLedger(this.memory);
307
311
  this.sessionStore = options.sessionStore ?? null;
308
312
  this._validate();
309
313
  this.pollRunner = createDeclaredPollRunner(this.options, {
@@ -424,7 +428,7 @@ export class Agent {
424
428
  // is built once and has no laneKey, so it derives one the same way the
425
429
  // event queue does (see laneKeyFor).
426
430
  wakeScheduler: {
427
- armWakeAt: (sessionId, at, reason) => this.stateTimers.armWakeAt(laneKeyFor(this.options.agentId, sessionId), at, reason, this.wakeEffects.getStore()?.scope),
431
+ armWakeAt: (sessionId, at, reason) => this.armOwnWake(sessionId, at, reason),
428
432
  },
429
433
  });
430
434
  this.brain = this.options.brain?.kind === 'handler'
@@ -466,6 +470,10 @@ export class Agent {
466
470
  // nothing to redeliver, so its failure is logged and dropped here rather
467
471
  // than left as an unhandled rejection (made enqueue reject).
468
472
  this.stateTimers = new StateTimers((ev, laneKey) => {
473
+ // A check-back that fired is no longer waiting: a restart from here on
474
+ // must not fire it again.
475
+ if (ev.type === 'wake-at')
476
+ this.wakeLedger.done(laneKey);
469
477
  void this.eventQueue.enqueue(ev, laneKey).catch((err) => {
470
478
  runtimeLog.warn('Agent', `state-timer frame failed laneKey=${laneKey}: ${err?.message || err}`);
471
479
  });
@@ -1604,8 +1612,63 @@ export class Agent {
1604
1612
  }
1605
1613
  this.pollRunner?.start();
1606
1614
  this.started = true;
1615
+ // Not awaited: starting stays synchronous, so a second connect in the
1616
+ // same turn still sees `started`. A wake re-armed a moment later is fine.
1617
+ void this.restoreWakes();
1607
1618
  return this;
1608
1619
  }
1620
+ /**
1621
+ * Arm the lane's timer for the agent's own `wake_at`, and save it so a
1622
+ * restart arms it again.
1623
+ */
1624
+ armOwnWake(sessionId, at, reason) {
1625
+ const laneKey = laneKeyFor(this.options.agentId, sessionId);
1626
+ const scope = this.wakeEffects.getStore()?.scope;
1627
+ const armed = this.stateTimers.armWakeAt(laneKey, at, reason, scope);
1628
+ this.wakeLedger.arm({
1629
+ laneKey,
1630
+ sessionId,
1631
+ firesAt: armed.firesAt,
1632
+ reason,
1633
+ ...(scope ? { scope } : {}),
1634
+ });
1635
+ return { ...armed, durable: this.wakeLedger.durable };
1636
+ }
1637
+ /**
1638
+ * Arm again the check-backs this agent asked for before the process went
1639
+ * down. A wake whose time passed in the meantime fires on the next turn,
1640
+ * once. The lane gets a session first, since a timer frame for a lane with
1641
+ * no session is dropped.
1642
+ */
1643
+ async restoreWakes() {
1644
+ const agentId = this.options.agentId;
1645
+ if (!agentId)
1646
+ return;
1647
+ let pending;
1648
+ try {
1649
+ pending = await this.wakeLedger.pending();
1650
+ }
1651
+ catch (err) {
1652
+ runtimeLog.warn(this.options.name || 'agent', `could not read saved wakes; they will not be re-armed: ${err?.message || err}`);
1653
+ return;
1654
+ }
1655
+ for (const wake of pending) {
1656
+ if (wake.laneKey !== laneKeyFor(agentId, wake.sessionId))
1657
+ continue;
1658
+ if (this.stateTimers.peek(wake.laneKey))
1659
+ continue;
1660
+ if (!this.sessions.has(wake.laneKey)) {
1661
+ this.sessions.set(wake.laneKey, { sessionId: wake.sessionId, ownAgentId: agentId });
1662
+ }
1663
+ try {
1664
+ this.stateTimers.armWakeAt(wake.laneKey, wake.firesAt, wake.reason, wake.scope);
1665
+ runtimeLog.info(this.options.name || 'agent', `re-armed a saved wake laneKey=${wake.laneKey} at=${new Date(wake.firesAt).toISOString()}`);
1666
+ }
1667
+ catch (err) {
1668
+ runtimeLog.warn(this.options.name || 'agent', `could not re-arm a saved wake laneKey=${wake.laneKey}: ${err?.message || err}`);
1669
+ }
1670
+ }
1671
+ }
1609
1672
  /**
1610
1673
  * plain string compare against {@link AgentOptions.requestMatchTags}
1611
1674
  * and `specialization`. No match → return (caller acks). Match → normal wake.
@@ -38,8 +38,10 @@ export declare const MAX_WAKE_DELAY_MS: number;
38
38
  * queued timeout frame whose epoch is not the lane's currently-armed epoch was
39
39
  * cancelled or replaced and must be dropped.
40
40
  *
41
- * In-memory only: process restart loses all timers; each lane re-arms on its
42
- * next real event (sessionStore persists { ctx, state } but not timers).
41
+ * The timers themselves live in memory. An agent's own `wake_at` is also
42
+ * written to its memory notes (`WakeLedger`), and the host arms those again
43
+ * when it starts; a host liveness timer is not, and re-arms on the lane's next
44
+ * real event.
43
45
  */
44
46
  export declare class StateTimers {
45
47
  private _timers;
@@ -67,10 +69,10 @@ export declare class StateTimers {
67
69
  * the agent changing its mind, which is allowed and is why this replaces
68
70
  * rather than adds.
69
71
  *
70
- * In memory only. A restart loses it; recovery requires the agent to re-arm
71
- * on its next real event. Returns what actually got armed so the caller
72
- * can tell the agent, since "I will wake at X" is a promise the agent will
73
- * otherwise make on its own.
72
+ * The timer is in memory; the Agent saves what it needs to arm it again
73
+ * after a restart. Returns what actually got armed so the caller can tell
74
+ * the agent, since "I will wake at X" is a promise the agent will otherwise
75
+ * make on its own.
74
76
  */
75
77
  armWakeAt(laneKey: string, at: Date | number, reason?: string | null, scope?: WorkScope): {
76
78
  firesAt: number;
@@ -14,8 +14,10 @@ export const MAX_WAKE_DELAY_MS = 2 ** 31 - 1;
14
14
  * queued timeout frame whose epoch is not the lane's currently-armed epoch was
15
15
  * cancelled or replaced and must be dropped.
16
16
  *
17
- * In-memory only: process restart loses all timers; each lane re-arms on its
18
- * next real event (sessionStore persists { ctx, state } but not timers).
17
+ * The timers themselves live in memory. An agent's own `wake_at` is also
18
+ * written to its memory notes (`WakeLedger`), and the host arms those again
19
+ * when it starts; a host liveness timer is not, and re-arms on the lane's next
20
+ * real event.
19
21
  */
20
22
  export class StateTimers {
21
23
  _timers = new Map();
@@ -68,10 +70,10 @@ export class StateTimers {
68
70
  * the agent changing its mind, which is allowed and is why this replaces
69
71
  * rather than adds.
70
72
  *
71
- * In memory only. A restart loses it; recovery requires the agent to re-arm
72
- * on its next real event. Returns what actually got armed so the caller
73
- * can tell the agent, since "I will wake at X" is a promise the agent will
74
- * otherwise make on its own.
73
+ * The timer is in memory; the Agent saves what it needs to arm it again
74
+ * after a restart. Returns what actually got armed so the caller can tell
75
+ * the agent, since "I will wake at X" is a promise the agent will otherwise
76
+ * make on its own.
75
77
  */
76
78
  armWakeAt(laneKey, at, reason = null, scope) {
77
79
  const firesAtRaw = at instanceof Date ? at.getTime() : Number(at);
@@ -0,0 +1,43 @@
1
+ import { type MemoryStore } from '../memory/MemoryStore.js';
2
+ import type { WorkScope } from '../runtime/wakeEffects.js';
3
+ /** One check-back an agent asked for, as it is kept across a restart. */
4
+ export interface SavedWake {
5
+ laneKey: string;
6
+ sessionId: string;
7
+ firesAt: number;
8
+ reason: string | null;
9
+ scope?: WorkScope;
10
+ }
11
+ /**
12
+ * Where an agent's own check-backs are kept, so a restart does not lose them.
13
+ *
14
+ * The timer itself stays in memory (`StateTimers`). This keeps the facts
15
+ * needed to arm it again: which lane, when, why, and under which work. A wake
16
+ * is written when it is armed and marked done when it fires, so on start the
17
+ * host re-arms exactly the ones still waiting, and one that passed while the
18
+ * host was down fires once.
19
+ *
20
+ * The notes live in the agent's own memory store under a prefix no memory
21
+ * section uses (sections are `hire:<id>`), so the agent's memory tools never
22
+ * see them. Writes are chained so a mark can never land before the arm it
23
+ * follows.
24
+ *
25
+ * Two hosts of the same agent running at once, as during a rolling deploy,
26
+ * can both re-arm a wake and both fire it. That costs one extra wake, and the
27
+ * agent checks for an answer before it chases anyone.
28
+ */
29
+ export declare class WakeLedger {
30
+ private readonly store;
31
+ private writes;
32
+ constructor(store: MemoryStore);
33
+ /** False when the store is in memory: then a restart loses wakes anyway. */
34
+ get durable(): boolean;
35
+ arm(wake: SavedWake): void;
36
+ done(laneKey: string): void;
37
+ /** The wakes still waiting to fire, for the host to arm again on start. */
38
+ pending(): Promise<SavedWake[]>;
39
+ /** Resolves when every write so far has landed. For tests and shutdown. */
40
+ flushed(): Promise<void>;
41
+ private write;
42
+ private warn;
43
+ }
@@ -0,0 +1,79 @@
1
+ import { runtimeLog } from '../shared/runtimeLog.js';
2
+ import { InMemoryStore } from '../memory/MemoryStore.js';
3
+ const PREFIX = 'host:wake-at:';
4
+ /**
5
+ * Where an agent's own check-backs are kept, so a restart does not lose them.
6
+ *
7
+ * The timer itself stays in memory (`StateTimers`). This keeps the facts
8
+ * needed to arm it again: which lane, when, why, and under which work. A wake
9
+ * is written when it is armed and marked done when it fires, so on start the
10
+ * host re-arms exactly the ones still waiting, and one that passed while the
11
+ * host was down fires once.
12
+ *
13
+ * The notes live in the agent's own memory store under a prefix no memory
14
+ * section uses (sections are `hire:<id>`), so the agent's memory tools never
15
+ * see them. Writes are chained so a mark can never land before the arm it
16
+ * follows.
17
+ *
18
+ * Two hosts of the same agent running at once, as during a rolling deploy,
19
+ * can both re-arm a wake and both fire it. That costs one extra wake, and the
20
+ * agent checks for an answer before it chases anyone.
21
+ */
22
+ export class WakeLedger {
23
+ store;
24
+ writes = Promise.resolve();
25
+ constructor(store) {
26
+ this.store = store;
27
+ }
28
+ /** False when the store is in memory: then a restart loses wakes anyway. */
29
+ get durable() {
30
+ return !(this.store instanceof InMemoryStore);
31
+ }
32
+ arm(wake) {
33
+ const { laneKey, ...rest } = wake;
34
+ this.write(laneKey, { ...rest, state: 'armed' });
35
+ }
36
+ done(laneKey) {
37
+ this.writes = this.writes
38
+ .then(async () => {
39
+ const saved = (await this.store.get(PREFIX + laneKey));
40
+ if (saved?.state === 'armed') {
41
+ await this.store.put(PREFIX + laneKey, { ...saved, state: 'done' });
42
+ }
43
+ })
44
+ .catch((err) => this.warn('mark done', laneKey, err));
45
+ }
46
+ /** The wakes still waiting to fire, for the host to arm again on start. */
47
+ async pending() {
48
+ await this.writes;
49
+ const rows = await this.store.list(PREFIX);
50
+ const out = [];
51
+ for (const { key, value } of rows) {
52
+ const saved = value;
53
+ if (saved?.state !== 'armed' || typeof saved.sessionId !== 'string')
54
+ continue;
55
+ if (!Number.isFinite(saved.firesAt))
56
+ continue;
57
+ out.push({
58
+ laneKey: key.slice(PREFIX.length),
59
+ sessionId: saved.sessionId,
60
+ firesAt: saved.firesAt,
61
+ reason: saved.reason ?? null,
62
+ ...(saved.scope ? { scope: saved.scope } : {}),
63
+ });
64
+ }
65
+ return out;
66
+ }
67
+ /** Resolves when every write so far has landed. For tests and shutdown. */
68
+ flushed() {
69
+ return this.writes;
70
+ }
71
+ write(laneKey, value) {
72
+ this.writes = this.writes
73
+ .then(() => this.store.put(PREFIX + laneKey, value))
74
+ .catch((err) => this.warn('save', laneKey, err));
75
+ }
76
+ warn(what, laneKey, err) {
77
+ runtimeLog.warn('WakeLedger', `could not ${what} the wake for laneKey=${laneKey}: ${err?.message || err}`);
78
+ }
79
+ }
@@ -10,13 +10,15 @@ import { type ToolDefinition } from '../../../tools/defineTool.js';
10
10
  *
11
11
  * What the platform learns from this: nothing. The wake is the agent's own
12
12
  * business — it arrives on the agent's lane, behind any real event, and no
13
- * server-side schedule is created.
13
+ * server-side schedule is created. The host keeps it in the agent's own
14
+ * memory notes, so a restart arms it again.
14
15
  */
15
16
  export interface WakeSchedulerPort {
16
17
  /** Arm the lane's timer. Returns when it will actually fire. */
17
18
  armWakeAt(sessionId: string, at: Date | number, reason: string | null): {
18
19
  firesAt: number;
19
20
  delayMs: number;
21
+ durable?: boolean;
20
22
  };
21
23
  }
22
24
  export interface WakeToolContext {
@@ -38,7 +38,9 @@ export const wakeAtTool = defineTool('wake_at', {
38
38
  wakeAt: new Date(armed.firesAt).toISOString(),
39
39
  inMs: armed.delayMs,
40
40
  reason,
41
- note: 'One wake is armed per lane, and arming again replaces it. This wake is held in memory: if the process restarts before it fires it is lost, and you will pick up on your next real event instead.',
41
+ note: armed.durable
42
+ ? 'One wake is armed per lane, and arming again replaces it. It is saved, so a restart of your host arms it again; if its time passed while the host was down, it wakes you once when the host is back.'
43
+ : 'One wake is armed per lane, and arming again replaces it. This wake is held in memory: if the process restarts before it fires it is lost, and you will pick up on your next real event instead.',
42
44
  };
43
45
  }, {
44
46
  description: 'Ask to be woken at a time, to check back on something you are waiting for. The wake arrives on this conversation, behind anything real that happens first. Arming again replaces the previous wake — a check-back schedule is a series of single wakes, so re-arm the next one each time you wake. Counts against your wake budget like any other wake.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.41.1",
3
+ "version": "0.41.2",
4
4
  "description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
5
5
  "homepage": "https://ziggsai.com/docs",
6
6
  "bugs": {
@@ -30,7 +30,7 @@
30
30
  "@anthropic-ai/claude-agent-sdk": "^0.3.284",
31
31
  "@anthropic-ai/sdk": "^0.110.0",
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
- "@ziggs-ai/api-client": "0.37.26",
33
+ "@ziggs-ai/api-client": "0.37.27",
34
34
  "@ziggs-ai/contracts": "^0.31.0",
35
35
  "ajv": "^8.17.1",
36
36
  "dotenv": "^17.2.3",