@ziggs-ai/agent-sdk 0.4.1 → 0.6.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/AgentHost.d.ts +21 -0
  2. package/dist/AgentHost.js +61 -13
  3. package/dist/agent/Agent.d.ts +9 -0
  4. package/dist/agent/ClaudeSdkAgent.d.ts +1 -1
  5. package/dist/agent/ClaudeSdkAgent.js +47 -12
  6. package/dist/agent/claudeSdkTools.js +1 -1
  7. package/dist/defineAgent.d.ts +10 -5
  8. package/dist/defineAgent.js +16 -9
  9. package/dist/runtime/runTurn.js +12 -2
  10. package/dist/runtime/taskIntake.d.ts +18 -0
  11. package/dist/runtime/taskIntake.js +113 -0
  12. package/dist/server/ConnectionPool.d.ts +9 -2
  13. package/dist/server/ConnectionPool.js +18 -2
  14. package/dist/server/inboxLoop.d.ts +31 -11
  15. package/dist/server/inboxLoop.js +134 -104
  16. package/dist/server/runLauncher.js +9 -3
  17. package/dist/server/staleBacklogGate.d.ts +9 -5
  18. package/dist/server/staleBacklogGate.js +17 -37
  19. package/dist/server/tools/tier1/protocolTools.js +2 -2
  20. package/dist/server/tools/tier2/artifactTools.d.ts +1 -1
  21. package/dist/server/tools/tier2/artifactTools.js +7 -82
  22. package/dist/server/tools/tier2/chatTools.d.ts +1 -1
  23. package/dist/server/tools/tier2/chatTools.js +7 -37
  24. package/dist/server/tools/tier2/connectionTools.d.ts +1 -1
  25. package/dist/server/tools/tier2/connectionTools.js +20 -94
  26. package/dist/server/tools/tier2/contextTools.d.ts +2 -2
  27. package/dist/server/tools/tier2/contextTools.js +10 -139
  28. package/dist/server/tools/tier2/discoveryTools.d.ts +1 -1
  29. package/dist/server/tools/tier2/discoveryTools.js +7 -24
  30. package/dist/server/tools/tier2/grantsTools.d.ts +1 -1
  31. package/dist/server/tools/tier2/grantsTools.js +6 -81
  32. package/dist/server/tools/tier2/linkTools.d.ts +1 -1
  33. package/dist/server/tools/tier2/linkTools.js +12 -156
  34. package/dist/server/tools/tier2/paymentTools.d.ts +2 -2
  35. package/dist/server/tools/tier2/paymentTools.js +16 -161
  36. package/dist/tools/capabilityAdapter.d.ts +11 -0
  37. package/dist/tools/capabilityAdapter.js +30 -0
  38. package/dist/types.d.ts +3 -2
  39. package/package.json +2 -2
@@ -40,6 +40,19 @@ export declare const DEFAULT_MODEL = "claude-haiku-4-5";
40
40
  * could bucket by taskId, cron agents by schedule id, etc.
41
41
  */
42
42
  export type SessionResolver = (normalized: NormalizedEvent, ownAgentId: string) => string | null;
43
+ /**
44
+ * ZIG-975: one lane key per unit of work, derived the same way as inbox wakes.
45
+ *
46
+ * Inbox deliveries key on `chatId` (DEFAULT_SESSION_RESOLVER). Bootstrap used to
47
+ * prefer bare `agreementId`, which opened a second lane for the same task and
48
+ * made the snapshot read treat the agreement id as a chat (`chatMissing: true`,
49
+ * empty context). Prefer the origin chat when present; otherwise the agreement's
50
+ * notification chat (`agrn-<agreementId>`), which is always linked after ZIG-966.
51
+ */
52
+ export declare function laneSessionIdForTask(task: {
53
+ originChatId?: string | null;
54
+ agreementId?: string | null;
55
+ }): string;
43
56
  interface LlmAdapter {
44
57
  chatMessages(messages: unknown[], tools?: unknown[], options?: Record<string, unknown>): Promise<unknown>;
45
58
  }
@@ -104,6 +117,14 @@ export interface AgentHostOptions {
104
117
  sessionStore?: SessionStore;
105
118
  /** Observability tap: called after every effect with the effect and its response. */
106
119
  effectTap?: (effect: import('./types.js').Effect, response: unknown) => void;
120
+ /**
121
+ * Liveness signal: called whenever the host receives or processes work (an
122
+ * inbox message or resource event delivered to a lane). Hosting pools use it
123
+ * to reset their idle-eviction timer — without it a host that is actively
124
+ * working (but never addressed via pool.sendTo) is torn down mid-stream at
125
+ * the idle timeout, discarding lane state, Claude session ids, and timers.
126
+ */
127
+ onActivity?: () => void;
107
128
  /** Observability tap: called on every FSM state transition, no-match, depth-guard, or error-escape (ZIG-442). */
108
129
  transitionTap?: (event: import('./runtime/AgentMachine.js').TransitionEvent) => void;
109
130
  /**
package/dist/AgentHost.js CHANGED
@@ -36,6 +36,24 @@ async function loadConnectionPool() {
36
36
  return mod.ConnectionPool;
37
37
  }
38
38
  const DEFAULT_SESSION_RESOLVER = (normalized) => normalized.chatId;
39
+ /**
40
+ * ZIG-975: one lane key per unit of work, derived the same way as inbox wakes.
41
+ *
42
+ * Inbox deliveries key on `chatId` (DEFAULT_SESSION_RESOLVER). Bootstrap used to
43
+ * prefer bare `agreementId`, which opened a second lane for the same task and
44
+ * made the snapshot read treat the agreement id as a chat (`chatMissing: true`,
45
+ * empty context). Prefer the origin chat when present; otherwise the agreement's
46
+ * notification chat (`agrn-<agreementId>`), which is always linked after ZIG-966.
47
+ */
48
+ export function laneSessionIdForTask(task) {
49
+ if (typeof task.originChatId === 'string' && task.originChatId) {
50
+ return task.originChatId;
51
+ }
52
+ if (typeof task.agreementId === 'string' && task.agreementId) {
53
+ return `agrn-${task.agreementId}`;
54
+ }
55
+ return 'default';
56
+ }
39
57
  function createToolManager(options) {
40
58
  const toolManager = new ToolManager();
41
59
  const userTools = options.tools || options.services?.tools || [];
@@ -147,7 +165,15 @@ export class AgentHost {
147
165
  };
148
166
  this.inboxLoopEnabled = options.inboxCatchUp !== false;
149
167
  this.sessionResolver = options.sessionResolver || DEFAULT_SESSION_RESOLVER;
150
- this.tickTimeoutMs = options.tickTimeoutMs ?? 120_000;
168
+ // A Claude-brain wake legitimately runs long (multi-turn query, prose
169
+ // deliverables) — 120s watchdogged real spec-writing wakes mid-flight,
170
+ // which then redelivered and re-ran (ZIG-924 dogfood, 2026-07-26). FSM
171
+ // turns stay on the tight default.
172
+ this.tickTimeoutMs =
173
+ options.tickTimeoutMs ??
174
+ (isClaudeCognition(options.cognition)
175
+ ? Number(process.env.CLAUDE_SDK_TICK_TIMEOUT_MS ?? 300_000)
176
+ : 120_000);
151
177
  this.memory = options.memory ?? new InMemoryStore();
152
178
  this.sessionStore = options.sessionStore ?? null;
153
179
  this._validate();
@@ -375,11 +401,19 @@ export class AgentHost {
375
401
  // armed pinned one live 2-minute timer per tick (and kept short-lived
376
402
  // processes/tests alive until it fired).
377
403
  let tickWatchdog;
404
+ // Aborting on timeout is load-bearing: Promise.race only abandons the tick
405
+ // promise. A Claude-brain tick left unaborted keeps its query() running as
406
+ // a zombie — burning tokens and racing the redelivered retry of the same
407
+ // event (duplicate sends, runaway per-wake input).
408
+ const tickAbort = new AbortController();
378
409
  try {
379
410
  const result = await Promise.race([
380
- this.agent.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff: this.eff, memory: this.memory, transitionTap: this.options.transitionTap }),
411
+ this.agent.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff: this.eff, memory: this.memory, transitionTap: this.options.transitionTap, signal: tickAbort.signal }),
381
412
  new Promise((_, reject) => {
382
- tickWatchdog = setTimeout(() => reject(new Error('tick timeout')), this.tickTimeoutMs);
413
+ tickWatchdog = setTimeout(() => {
414
+ tickAbort.abort(new Error('tick timeout'));
415
+ reject(new Error('tick timeout'));
416
+ }, this.tickTimeoutMs);
383
417
  }),
384
418
  ]);
385
419
  session.ctx = result.ctx;
@@ -475,8 +509,26 @@ export class AgentHost {
475
509
  ownAgentId: this.options.agentId,
476
510
  seen: this.seenMessages,
477
511
  label,
478
- onMessage: (text, metadata) => this.handleMessage(text, metadata),
479
- onResourceEvent: (event) => this.handleResourceEvent(event),
512
+ onMessage: (text, metadata) => {
513
+ this.options.onActivity?.();
514
+ return this.handleMessage(text, metadata);
515
+ },
516
+ onResourceEvent: (event) => {
517
+ this.options.onActivity?.();
518
+ return this.handleResourceEvent(event);
519
+ },
520
+ // ZIG-973: work delivered by the inbox's task channel, not by a chat
521
+ // row — this is what reaches a task under a chat-less agreement or one
522
+ // the agent assigned to itself.
523
+ onTask: async (task) => {
524
+ this.options.onActivity?.();
525
+ try {
526
+ await this.wakeTaskAssignment(task.taskId);
527
+ }
528
+ catch (err) {
529
+ runtimeLog.warn(this.options.name || 'agent', `inbox task ${task.taskId} could not be woken: ${err.message}`);
530
+ }
531
+ },
480
532
  });
481
533
  }
482
534
  return this;
@@ -509,9 +561,7 @@ export class AgentHost {
509
561
  const state = String(full.state ?? '');
510
562
  if (state !== 'active' && state !== 'in-progress')
511
563
  continue;
512
- const sessionId = full.agreementId ||
513
- full.originChatId ||
514
- 'default';
564
+ const sessionId = laneSessionIdForTask(full);
515
565
  const laneKey = `${agentId}:${sessionId}`;
516
566
  if (!this.sessions.has(laneKey)) {
517
567
  this.sessions.set(laneKey, { sessionId, ownAgentId: agentId });
@@ -525,7 +575,7 @@ export class AgentHost {
525
575
  state,
526
576
  providerIsYou: true,
527
577
  },
528
- chatId: full.originChatId,
578
+ chatId: sessionId,
529
579
  }, laneKey);
530
580
  }
531
581
  }
@@ -540,9 +590,7 @@ export class AgentHost {
540
590
  if (state !== 'active' && state !== 'in-progress') {
541
591
  throw new Error(`wakeTaskAssignment: task ${taskId} is ${state}, not active`);
542
592
  }
543
- const sessionId = full.agreementId ||
544
- full.originChatId ||
545
- 'default';
593
+ const sessionId = laneSessionIdForTask(full);
546
594
  const laneKey = `${agentId}:${sessionId}`;
547
595
  if (!this.sessions.has(laneKey)) {
548
596
  this.sessions.set(laneKey, { sessionId, ownAgentId: agentId });
@@ -555,7 +603,7 @@ export class AgentHost {
555
603
  state,
556
604
  providerIsYou: true,
557
605
  },
558
- chatId: full.originChatId,
606
+ chatId: sessionId,
559
607
  }, laneKey);
560
608
  }
561
609
  disconnect() { this.stateTimers.clearAll(); void this.inboxLoop?.stop(); this.inboxLoop = null; }
@@ -27,6 +27,15 @@ export interface TickInput {
27
27
  memory?: MemoryStore;
28
28
  /** Optional tap for FSM transition events (ZIG-442). */
29
29
  transitionTap?: (event: TransitionEvent) => void;
30
+ /**
31
+ * Aborted by the host when the tick watchdog fires. A brain driving an
32
+ * external loop (the Claude Agent SDK query) MUST stop consuming it on
33
+ * abort — the host's Promise.race only abandons the tick promise, so
34
+ * without this the query keeps running as a zombie: burning tokens and
35
+ * racing its own redelivered retry (observed as duplicate kickoffs and a
36
+ * 363k-token wake, 2026-07-26).
37
+ */
38
+ signal?: AbortSignal;
30
39
  }
31
40
  export interface TickOutput {
32
41
  /** Updated ctx — Server persists this against the session. */
@@ -39,5 +39,5 @@ export declare class ClaudeSdkAgent {
39
39
  private statelessPerWake;
40
40
  private queryFn;
41
41
  constructor(opts: ClaudeSdkAgentOptions);
42
- tick({ event, ctx, state, identity, eff }: TickInput): Promise<TickOutput>;
42
+ tick({ event, ctx, state, identity, eff, signal }: TickInput): Promise<TickOutput>;
43
43
  }
@@ -163,7 +163,7 @@ export class ClaudeSdkAgent {
163
163
  this.statelessPerWake = opts.statelessPerWake ?? false;
164
164
  this.queryFn = opts.queryFn ?? query;
165
165
  }
166
- async tick({ event, ctx, state, identity, eff }) {
166
+ async tick({ event, ctx, state, identity, eff, signal }) {
167
167
  if (!identity?.sessionId)
168
168
  throw new Error('ClaudeSdkAgent.tick: identity.sessionId is required');
169
169
  if (!eff)
@@ -199,16 +199,32 @@ export class ClaudeSdkAgent {
199
199
  // Same guard as the FSM's runTurn: a message wake for a chat the backend
200
200
  // no longer resolves (deleted / not visible) has no world to act in —
201
201
  // skip the query instead of burning a wake on a fabricated empty history.
202
- // Task lanes (sessionId = agreementId) also trip chatMissing and must run.
203
- if (contextSnap?.chatMissing === true && nextCtx.lastOutcome?.kind === 'message-received') {
204
- runtimeLog.warn('ClaudeSdkAgent', `chat ${identity.sessionId} not found on backend — skipping message wake agent=${this.agentId}`);
205
- return {
206
- ctx: nextCtx,
207
- state: state ?? DEFAULT_STATE,
208
- parked: true,
209
- stateTimeoutUnhandled: false,
210
- metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
211
- };
202
+ //
203
+ // ZIG-975: a bare agreement id (`agr_…`, not the `agrn-…` notification
204
+ // chat) is not a chat. If a wake still keys the lane that way, read-context
205
+ // returns chatMissing and used to continue on an empty snapshot with no
206
+ // signal. Loud-log that mis-key; message wakes still skip below.
207
+ if (contextSnap?.chatMissing === true) {
208
+ const sessionId = identity.sessionId;
209
+ const looksLikeBareAgreement = typeof sessionId === 'string' &&
210
+ sessionId.startsWith('agr_') &&
211
+ !sessionId.startsWith('agrn-');
212
+ if (looksLikeBareAgreement) {
213
+ runtimeLog.warn('ClaudeSdkAgent', `chatMissing on non-chat sessionId=${sessionId} agent=${this.agentId} ` +
214
+ `outcome=${nextCtx.lastOutcome?.kind ?? 'none'} — lane was keyed by ` +
215
+ `agreementId instead of the agreement's origin/notification chat; ` +
216
+ `wake continues with an empty snapshot`);
217
+ }
218
+ if (nextCtx.lastOutcome?.kind === 'message-received') {
219
+ runtimeLog.warn('ClaudeSdkAgent', `chat ${sessionId} not found on backend — skipping message wake agent=${this.agentId}`);
220
+ return {
221
+ ctx: nextCtx,
222
+ state: state ?? DEFAULT_STATE,
223
+ parked: true,
224
+ stateTimeoutUnhandled: false,
225
+ metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
226
+ };
227
+ }
212
228
  }
213
229
  const roleBlock = [
214
230
  `## Your role`,
@@ -264,8 +280,24 @@ export class ClaudeSdkAgent {
264
280
  let metrics = { llmCalls: 0, tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } };
265
281
  let sessionId = nextCtx.claudeSessionId;
266
282
  let spentInput = 0;
283
+ // Host watchdog abort (see TickInput.signal): stop consuming AND interrupt
284
+ // the query, so a timed-out wake never lingers as a token-burning zombie
285
+ // racing its own redelivered retry. Breaking the for-await returns the
286
+ // generator; interrupt() additionally stops a query blocked mid-turn.
287
+ const query = this.queryFn({ prompt, options: queryOptions });
288
+ const onAbort = () => {
289
+ void query.interrupt?.()?.catch(() => { });
290
+ };
291
+ if (signal?.aborted)
292
+ onAbort();
293
+ else
294
+ signal?.addEventListener('abort', onAbort, { once: true });
267
295
  try {
268
- for await (const message of this.queryFn({ prompt, options: queryOptions })) {
296
+ for await (const message of query) {
297
+ if (signal?.aborted) {
298
+ runtimeLog.warn('ClaudeSdkAgent', `host watchdog aborted wake session=${identity.sessionId} agent=${this.agentId} — stopping query`);
299
+ break;
300
+ }
269
301
  if ('session_id' in message && typeof message.session_id === 'string') {
270
302
  sessionId = message.session_id;
271
303
  }
@@ -312,6 +344,9 @@ export class ClaudeSdkAgent {
312
344
  runtimeLog.warn('ClaudeSdkAgent', `query failed session=${identity.sessionId}: ${err.message}`);
313
345
  throw err;
314
346
  }
347
+ finally {
348
+ signal?.removeEventListener('abort', onAbort);
349
+ }
315
350
  // Stateless-per-wake keeps no session id — clear any carried over from the
316
351
  // prior ctx so the next wake starts fresh too (ZIG-860). Otherwise remember
317
352
  // this wake's session for resume.
@@ -26,7 +26,7 @@ export const ziggsContextSnapshotTool = defineTool('ziggs_context_snapshot', {
26
26
  maxMessages: args.maxMessages != null ? Number(args.maxMessages) : DEFAULT_SNAPSHOT_MAX_MESSAGES,
27
27
  },
28
28
  });
29
- }, { description: 'Read the chat snapshot: history, agreements, agents, and users for a session.' });
29
+ }, { description: 'Read the chat snapshot: the NEWEST maxMessages of history (chronological order), plus agreements, agents, and users for a session.' });
30
30
  export const ziggsSendMessageTool = defineTool('ziggs_send_message', {
31
31
  chatId: { type: 'string', description: 'Chat id. Defaults to current session.' },
32
32
  receiverId: { type: 'string', description: 'Recipient user or agent id. Defaults to the sender of the event that woke you — omit when replying to them.' },
@@ -2,11 +2,16 @@ import type { AgentConfig, DefineAgentInput } from './types.js';
2
2
  /**
3
3
  * Validates the workflow synchronously, resolves merged tools and model, and
4
4
  * returns a frozen AgentConfig. Never mutates the input. `wait` defaults remain
5
- * the author's visible spread of `thinkingDefaults`. ONE default is injected (as
6
- * new objects): the agreement-kickoff state `kickingOff` plus a last-position
7
- * transition into it, appended to every state that does not already handle a
8
- * proposer-side proposal-resolved/approve outcome (runtime/kickoff.ts). The
9
- * state name 'kickingOff' is reserved; defining it throws.
5
+ * the author's visible spread of `thinkingDefaults`. TWO defaults are injected
6
+ * (as new objects), each appended LAST so author transitions always win:
7
+ * - the agreement-kickoff state `kickingOff`, reached from every state that
8
+ * does not already handle a proposer-side proposal-resolved/approve outcome
9
+ * (runtime/kickoff.ts);
10
+ * - the assigned-task-intake state `workingAssignedTask`, reached from every
11
+ * state that does not already route a task-assigned outcome
12
+ * (runtime/taskIntake.ts) — so a standing hire's task #2..N is never
13
+ * silently dropped by a parked state.
14
+ * Both state names are reserved; defining either throws.
10
15
  *
11
16
  * Validation throws WorkflowValidationError on hard problems and logs warnings
12
17
  * on soft ones (e.g. missing wait fallthrough).
@@ -1,16 +1,22 @@
1
1
  import { validateWorkflow } from './runtime/validateWorkflow.js';
2
2
  import { injectKickoffDefault } from './runtime/kickoff.js';
3
+ import { injectTaskIntakeDefault } from './runtime/taskIntake.js';
3
4
  import { deriveRespondsToProposals } from './runtime/deriveRespondsToProposals.js';
4
5
  import { runtimeLog } from './shared/runtimeLog.js';
5
6
  import { DEFAULT_MODEL } from './AgentHost.js';
6
7
  /**
7
8
  * Validates the workflow synchronously, resolves merged tools and model, and
8
9
  * returns a frozen AgentConfig. Never mutates the input. `wait` defaults remain
9
- * the author's visible spread of `thinkingDefaults`. ONE default is injected (as
10
- * new objects): the agreement-kickoff state `kickingOff` plus a last-position
11
- * transition into it, appended to every state that does not already handle a
12
- * proposer-side proposal-resolved/approve outcome (runtime/kickoff.ts). The
13
- * state name 'kickingOff' is reserved; defining it throws.
10
+ * the author's visible spread of `thinkingDefaults`. TWO defaults are injected
11
+ * (as new objects), each appended LAST so author transitions always win:
12
+ * - the agreement-kickoff state `kickingOff`, reached from every state that
13
+ * does not already handle a proposer-side proposal-resolved/approve outcome
14
+ * (runtime/kickoff.ts);
15
+ * - the assigned-task-intake state `workingAssignedTask`, reached from every
16
+ * state that does not already route a task-assigned outcome
17
+ * (runtime/taskIntake.ts) — so a standing hire's task #2..N is never
18
+ * silently dropped by a parked state.
19
+ * Both state names are reserved; defining either throws.
14
20
  *
15
21
  * Validation throws WorkflowValidationError on hard problems and logs warnings
16
22
  * on soft ones (e.g. missing wait fallthrough).
@@ -24,14 +30,15 @@ export function defineAgent(input) {
24
30
  throw new Error('[defineAgent] states is required');
25
31
  if (!input?.initial)
26
32
  throw new Error('[defineAgent] initial state is required');
27
- // Agreement-kickoff default (see runtime/kickoff.ts): injected BEFORE
28
- // validation so the injected state/transitions are validated too.
29
- const workflow = injectKickoffDefault({
33
+ // Agreement-kickoff + assigned-task-intake defaults (runtime/kickoff.ts,
34
+ // runtime/taskIntake.ts): injected BEFORE validation so the injected
35
+ // states/transitions are validated too.
36
+ const workflow = injectTaskIntakeDefault(injectKickoffDefault({
30
37
  id: input.agentId,
31
38
  description: input.description,
32
39
  initial: input.initial,
33
40
  states: input.states,
34
- });
41
+ }));
35
42
  const { warnings } = validateWorkflow(workflow);
36
43
  for (const w of warnings)
37
44
  runtimeLog.warn('defineAgent', w);
@@ -105,8 +105,18 @@ export async function runTurn(input) {
105
105
  // to this agent) has no world to decide over — deciding anyway means
106
106
  // reasoning about a fabricated empty history plus whatever stale scraps
107
107
  // other fetches returned. Skip the LLM and park. Gated on message wakes
108
- // only: agreement-keyed task lanes (sessionId = agreementId) always trip
109
- // chatMissing and must still run.
108
+ // only. ZIG-975: a bare agreement id as sessionId is a mis-keyed lane —
109
+ // loud-log the empty snapshot rather than staying silent.
110
+ if (ctxObj.chatMissing) {
111
+ const looksLikeBareAgreement = typeof sessionId === 'string' &&
112
+ sessionId.startsWith('agr_') &&
113
+ !sessionId.startsWith('agrn-');
114
+ if (looksLikeBareAgreement) {
115
+ runtimeLog.warn('runTurn', `chatMissing on non-chat sessionId=${sessionId} (state=${stateId}) — ` +
116
+ `lane was keyed by agreementId instead of the agreement's origin/notification chat; ` +
117
+ `wake continues with an empty snapshot`);
118
+ }
119
+ }
110
120
  if (ctxObj.chatMissing && ctx.lastOutcome?.kind === 'message-received') {
111
121
  runtimeLog.warn('runTurn', `chat ${sessionId} not found on backend — skipping message wake (state=${stateId})`);
112
122
  return {
@@ -0,0 +1,18 @@
1
+ import type { ThinkingState, Workflow } from '../types.js';
2
+ /** Reserved state name for the injected assigned-task-intake default. */
3
+ export declare const TASK_INTAKE_STATE = "workingAssignedTask";
4
+ export declare function buildTaskIntakeState(initial: string): ThinkingState;
5
+ /**
6
+ * Assigned-task-intake default — the task-side twin of the agreement-kickoff
7
+ * default (kickoff.ts). For every state whose transitions do not already match
8
+ * a task-assigned outcome, append a transition to the injected
9
+ * `workingAssignedTask` state. Appended LAST so any user transition on the
10
+ * same outcome always wins (first match wins in
11
+ * AgentMachine._evaluateTransitions). Returns a NEW workflow — input states
12
+ * are not mutated.
13
+ *
14
+ * Why: a standing hire receives task #2, #3, … months after approval. Without
15
+ * this default a parked state with no task-assigned route (e.g. a hire parked
16
+ * in `engaged`) swallowed the wake and the task sat active forever.
17
+ */
18
+ export declare function injectTaskIntakeDefault(workflow: Workflow): Workflow;
@@ -0,0 +1,113 @@
1
+ import { thinkingDefaults } from './defaults.js';
2
+ import { KICKOFF_STATE } from './kickoff.js';
3
+ import { PROBE_CTX } from './validateWorkflow.js';
4
+ /** Reserved state name for the injected assigned-task-intake default. */
5
+ export const TASK_INTAKE_STATE = 'workingAssignedTask';
6
+ // Synthetic outcome used to ask "does this state already route an assigned
7
+ // task?" — same probe pattern as kickoff.ts / validateWorkflow.
8
+ const PROBE_TASK_ASSIGNED = {
9
+ kind: 'task-assigned',
10
+ task: { taskId: '__task_intake_probe__' },
11
+ };
12
+ const TASK_INTAKE_TRANSITION = {
13
+ to: TASK_INTAKE_STATE,
14
+ when: o => o.kind === 'task-assigned',
15
+ };
16
+ // A state "handles" task-assigned if some transition would fire for it. An
17
+ // unconditional (when-less) transition counts — it catches every outcome, so
18
+ // the intake transition appended LAST would be unreachable there anyway.
19
+ function stateHandlesTaskAssigned(state) {
20
+ return state.transitions.some(t => {
21
+ if (!t.when)
22
+ return true;
23
+ try {
24
+ return !!t.when(PROBE_TASK_ASSIGNED, PROBE_CTX);
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ });
30
+ }
31
+ export function buildTaskIntakeState(initial) {
32
+ const dflt = thinkingDefaults({ initial });
33
+ return {
34
+ kind: 'thinking',
35
+ prompt: {
36
+ role: 'A task was just assigned to you under one of your active agreements, and your workflow has no explicit route for it. This injected default exists so an assigned task is NEVER silently dropped — under a standing hire, new tasks keep arriving for the lifetime of the agreement.',
37
+ goal: 'Resolve the assigned task this turn: deliver the result if you can produce it, otherwise fail it with a reason.',
38
+ constraints: 'The task is in <event> (and as activeTaskId / activeAgreementId in <machine_context>). If the deliverable can be produced in this turn (an answer, summary, plan, draft — most representation work), call task_update with status "completed" and the deliverable as result — the result is what the counterparty consumes, so make it the actual deliverable, not a status note. If the task genuinely needs abilities or tools you do not have, call task_update with status "failed" and a one-line reason — a visible failure the counterparty can re-route beats a silent drop. Never leave the task dangling or answer with a chat message alone.',
39
+ },
40
+ actions: {
41
+ ...dflt.actions,
42
+ completeTask: {
43
+ prompt: {
44
+ when: 'You can produce the deliverable for the assigned task in this turn.',
45
+ instruction: 'Complete the assigned task, with the deliverable itself as the result.',
46
+ format: '{"taskId": "<assigned task id from <event>>", "status": "completed", "result": "<the full deliverable>"}',
47
+ },
48
+ tool: 'task_update',
49
+ // Function form so a failed task_update surfaces as an error outcome
50
+ // (→ dflt error transition) instead of a phantom task-closed.
51
+ produces: (result) => {
52
+ const r = result;
53
+ const status = r?.task?.state ?? r?.state ?? r?.status;
54
+ return status === 'completed' || status === 'failed' ? 'task-closed' : 'error';
55
+ },
56
+ terminatesLoop: true,
57
+ },
58
+ failTask: {
59
+ prompt: {
60
+ when: 'The task needs abilities, tools, or information you cannot obtain this turn.',
61
+ instruction: 'Fail the assigned task with a one-line reason so the counterparty can re-route it.',
62
+ format: '{"taskId": "<assigned task id from <event>>", "status": "failed", "result": "<one-line reason>"}',
63
+ },
64
+ tool: 'task_update',
65
+ produces: (result) => {
66
+ const r = result;
67
+ const status = r?.task?.state ?? r?.state ?? r?.status;
68
+ return status === 'completed' || status === 'failed' ? 'task-closed' : 'error';
69
+ },
70
+ terminatesLoop: true,
71
+ },
72
+ },
73
+ transitions: [
74
+ { to: initial, when: o => o.kind === 'task-closed' },
75
+ ...dflt.transitions, // wait / message-sent / error → initial
76
+ ],
77
+ };
78
+ }
79
+ /**
80
+ * Assigned-task-intake default — the task-side twin of the agreement-kickoff
81
+ * default (kickoff.ts). For every state whose transitions do not already match
82
+ * a task-assigned outcome, append a transition to the injected
83
+ * `workingAssignedTask` state. Appended LAST so any user transition on the
84
+ * same outcome always wins (first match wins in
85
+ * AgentMachine._evaluateTransitions). Returns a NEW workflow — input states
86
+ * are not mutated.
87
+ *
88
+ * Why: a standing hire receives task #2, #3, … months after approval. Without
89
+ * this default a parked state with no task-assigned route (e.g. a hire parked
90
+ * in `engaged`) swallowed the wake and the task sat active forever.
91
+ */
92
+ export function injectTaskIntakeDefault(workflow) {
93
+ if (workflow.states[TASK_INTAKE_STATE]) {
94
+ throw new Error(`[defineAgent] state name "${TASK_INTAKE_STATE}" is reserved for the injected assigned-task-intake default — rename the state`);
95
+ }
96
+ const states = {};
97
+ let injected = false;
98
+ for (const [name, state] of Object.entries(workflow.states)) {
99
+ // Injected defaults never feed each other: the kickoff state's own lack of
100
+ // a task-assigned route must not force the intake state into a workflow
101
+ // whose author states all handle assignment.
102
+ if (name === KICKOFF_STATE || stateHandlesTaskAssigned(state)) {
103
+ states[name] = state;
104
+ continue;
105
+ }
106
+ states[name] = { ...state, transitions: [...state.transitions, TASK_INTAKE_TRANSITION] };
107
+ injected = true;
108
+ }
109
+ if (!injected)
110
+ return workflow; // every state routes task-assigned — nothing to add
111
+ states[TASK_INTAKE_STATE] = buildTaskIntakeState(workflow.initial);
112
+ return { ...workflow, states };
113
+ }
@@ -11,7 +11,7 @@
11
11
  * This is how a single Node process serves a fleet of agents without
12
12
  * holding 1000 open sockets — typical config: 1000+ registered, ~50 live.
13
13
  */
14
- import { type InboxOperatorAgentEntry } from '@ziggs-ai/api-client';
14
+ import { type InboxOperatorAgentEntry, type StartAgentOptions } from '@ziggs-ai/api-client';
15
15
  import { AgentHost, type AgentHostOptions } from '../AgentHost.js';
16
16
  interface ConnectionManagerMeta {
17
17
  domain?: string;
@@ -50,7 +50,14 @@ export declare class ConnectionPool {
50
50
  * `AgentHost` constructed + connected) the first time it's referenced.
51
51
  */
52
52
  register(configs: AgentHostOptions[], metaArr?: ConnectionManagerMeta[]): void;
53
- startAgent(agentId: string): Promise<AgentHost>;
53
+ /**
54
+ * Start (or return) an agent's host. `pinned` keeps it up for the process's
55
+ * lifetime — no idle sweep, last to be evicted; use it for hosts the operator
56
+ * named rather than ones work happened to reach.
57
+ */
58
+ startAgent(agentId: string, opts?: StartAgentOptions): Promise<AgentHost>;
59
+ /** Agents kept up explicitly (launcher `preStart`), not by demand. */
60
+ listPinned(): string[];
54
61
  sleep(agentId: string): Promise<void>;
55
62
  disconnectAll(): Promise<void>;
56
63
  /**
@@ -46,7 +46,14 @@ export class ConnectionPool {
46
46
  const pool = this;
47
47
  const host = new AgentHost({
48
48
  ...cfg,
49
+ // Inbox activity and in-flight effects both reset the idle timer:
50
+ // previously only pool.sendTo() touched it, so a host woken by its
51
+ // own inbox loop was torn down every idleTimeoutMs (default 60s)
52
+ // regardless of the work it was doing — losing lane ctx, Claude
53
+ // session ids, and armed timers each time.
54
+ onActivity: () => pool.manager.touch(agentId),
49
55
  effectTap(effect, response) {
56
+ pool.manager.touch(agentId);
50
57
  if (effect.kind === 'send-message') {
51
58
  const listeners = pool.responseListeners.get(agentId);
52
59
  if (listeners?.length)
@@ -64,8 +71,17 @@ export class ConnectionPool {
64
71
  }, metaByAgentId.get(agentId));
65
72
  }
66
73
  }
67
- async startAgent(agentId) {
68
- return await this.manager.startAgent(agentId);
74
+ /**
75
+ * Start (or return) an agent's host. `pinned` keeps it up for the process's
76
+ * lifetime — no idle sweep, last to be evicted; use it for hosts the operator
77
+ * named rather than ones work happened to reach.
78
+ */
79
+ async startAgent(agentId, opts = {}) {
80
+ return await this.manager.startAgent(agentId, opts);
81
+ }
82
+ /** Agents kept up explicitly (launcher `preStart`), not by demand. */
83
+ listPinned() {
84
+ return this.manager.listPinned();
69
85
  }
70
86
  async sleep(agentId) {
71
87
  return this.manager.sleep(agentId);
@@ -1,13 +1,17 @@
1
- import { type Creds } from '@ziggs-ai/api-client';
1
+ import { type InboxTaskRef, type Creds } from '@ziggs-ai/api-client';
2
2
  import { SeenMessages } from './SeenMessages.js';
3
3
  /**
4
4
  * Per-agent inbox long-poll loop (delivery law, phase 4). This is the ONLY way
5
- * a hosted agent consumes: `GET /inbox?wait=` → grant-fenced reads of the delta
6
- * → deliver into the host's lane/tick machinery → `POST /inbox/ack`. No socket,
7
- * no push, no separate catch-up channel — a crash-replay is just the next
8
- * iteration (the cursor is server-side), and writes carry idempotency keys so
9
- * re-processing a redelivered window is wasteful, never harmful. Act-before-ack
10
- * is the one ordering rule.
5
+ * a hosted agent consumes: `GET /inbox?wait=` → grant-fenced reads of what was
6
+ * addressed to it → deliver into the host's lane/tick machinery →
7
+ * `POST /inbox/ack`. No socket, no push, no separate catch-up channel — a
8
+ * crash-replay is just the next iteration (the watermark is server-side), and
9
+ * writes carry idempotency keys so re-processing a redelivered window is
10
+ * wasteful, never harmful. Act-before-ack is the one ordering rule.
11
+ *
12
+ * The envelope now names the chats directly, so this loop no longer expands
13
+ * scopes into chat ids itself — that cost an extra grant/agreement lookup per
14
+ * scope, per pass, purely to rediscover what the server already knew.
11
15
  */
12
16
  export interface InboxLoopDeps {
13
17
  creds: Creds;
@@ -16,13 +20,24 @@ export interface InboxLoopDeps {
16
20
  seen: SeenMessages;
17
21
  onMessage: (text: string, metadata: Record<string, unknown>) => Promise<void>;
18
22
  onResourceEvent: (event: Record<string, unknown>) => Promise<void>;
23
+ /**
24
+ * An open task assigned to this agent (ZIG-973). Delivered from the inbox's
25
+ * own task channel, so work reaches the agent even when no chat row exists
26
+ * for it (chat-less agreement, self-assigned task).
27
+ */
28
+ onTask?: (task: InboxTaskRef) => Promise<void>;
19
29
  label?: string;
20
30
  }
21
31
  export interface InboxPassResult {
22
- scopes: number;
32
+ /** Delivery references this envelope carried. */
33
+ deliveries: number;
23
34
  messagesDelivered: number;
24
35
  resourceEvents: number;
25
- acked: number;
36
+ /** Assigned open tasks delivered this pass (ZIG-973). */
37
+ tasksDelivered: number;
38
+ /** True when the watermark advanced this pass. */
39
+ acked: boolean;
40
+ /** True when there was more mail than one envelope carries. */
26
41
  truncated: boolean;
27
42
  }
28
43
  /**
@@ -42,8 +57,13 @@ export interface InboxLoopHandle {
42
57
  * Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
43
58
  * for `waitSeconds`; a passing envelope returns instantly, an empty one holds.
44
59
  * Errors retry with exponential backoff + jitter and never exit the loop —
45
- * there is nothing to resynchronize (the cursor is server-side), so recovery is
46
- * just the next iteration.
60
+ * there is nothing to resynchronize (the watermark is server-side), so recovery
61
+ * is just the next iteration.
62
+ *
63
+ * The FIRST pass is an immediate read. A held poll only answers to new mail, so
64
+ * a host that just started (or restarted after a crash) would otherwise sit
65
+ * waiting while the work it was already assigned — open tasks, pending
66
+ * decisions — went unseen until something unrelated arrived.
47
67
  */
48
68
  export declare function runInboxLoop(deps: InboxLoopDeps, opts?: {
49
69
  waitSeconds?: number;