@ziggs-ai/agent-sdk 0.11.0 → 0.12.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 (49) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/dist/AgentHost.d.ts +108 -6
  4. package/dist/AgentHost.js +290 -18
  5. package/dist/adapters/McpAdapter.js +10 -12
  6. package/dist/agent/ClaudeSdkAgent.d.ts +9 -0
  7. package/dist/agent/ClaudeSdkAgent.js +127 -26
  8. package/dist/agent/claudeSdkProtocol.d.ts +1 -1
  9. package/dist/agent/claudeSdkProtocol.js +3 -2
  10. package/dist/agent/claudeSdkTools.js +71 -16
  11. package/dist/defineAgent.js +31 -2
  12. package/dist/index.d.ts +7 -3
  13. package/dist/index.js +11 -1
  14. package/dist/pricing/fleetTierByAgentId.gen.d.ts +2 -2
  15. package/dist/pricing/fleetTierByAgentId.gen.js +9 -2
  16. package/dist/runtime/PromptBuilder.js +13 -1
  17. package/dist/server/ConnectionPool.d.ts +19 -0
  18. package/dist/server/ConnectionPool.js +30 -0
  19. package/dist/server/OutboxBuffer.d.ts +1 -1
  20. package/dist/server/ZiggsEffectHandler.d.ts +1 -1
  21. package/dist/server/ZiggsEffectHandler.js +16 -3
  22. package/dist/server/agreements/AgreementService.d.ts +8 -3
  23. package/dist/server/agreements/AgreementService.js +10 -5
  24. package/dist/server/inboxLoop.d.ts +6 -6
  25. package/dist/server/inboxLoop.js +39 -21
  26. package/dist/server/polls/PollRunner.d.ts +36 -1
  27. package/dist/server/polls/PollRunner.js +42 -2
  28. package/dist/server/requestMatch.d.ts +5 -0
  29. package/dist/server/{questMatch.js → requestMatch.js} +2 -2
  30. package/dist/server/runLauncher.js +24 -3
  31. package/dist/server/tasks/TaskService.d.ts +5 -5
  32. package/dist/server/tools/index.d.ts +1 -1
  33. package/dist/server/tools/index.js +1 -1
  34. package/dist/server/tools/tier1/protocolRunner.js +2 -2
  35. package/dist/server/tools/tier1/protocolTools.js +7 -7
  36. package/dist/server/tools/tier2/marketplaceTools.js +2 -2
  37. package/dist/server/tools/tier2/mcpConnectionTools.d.ts +33 -0
  38. package/dist/server/tools/tier2/mcpConnectionTools.js +139 -7
  39. package/dist/server/tools/tier2/paymentTools.d.ts +0 -8
  40. package/dist/server/tools/tier2/paymentTools.js +8 -12
  41. package/dist/tools/ToolManager.d.ts +1 -1
  42. package/dist/tools/ToolManager.js +2 -1
  43. package/dist/tools/defineTool.d.ts +7 -0
  44. package/dist/tools/defineTool.js +12 -0
  45. package/dist/tools/needs.d.ts +54 -0
  46. package/dist/tools/needs.js +84 -0
  47. package/dist/types.d.ts +34 -5
  48. package/package.json +2 -2
  49. package/dist/server/questMatch.d.ts +0 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Breaking
6
+
7
+ - `AgentHostOptions.questMatchTags` is now `requestMatchTags`. The option is an
8
+ allow-list field, so an agent still passing the old name has it silently
9
+ dropped rather than rejected — rename the call site.
10
+ - The `quest` vocabulary is renamed to `request` throughout: the inbox delivery
11
+ kind an agent reads, the `ClaimedKind` a claim returns, and the marketplace
12
+ listing fields. Re-exported api-client types move with it.
13
+
3
14
  ## 0.10.0 — unreleased
4
15
 
5
16
  ### Removed (breaking)
package/README.md CHANGED
@@ -91,7 +91,7 @@ The public **`Brain`** interface is `tick(TickInput): Promise<TickOutput>`. Buil
91
91
  2. Pass **`openaiKey`**, **`operatorKey`** (Ziggs operator token), **`agentId`**, etc., and **`createAgent(config)`** (returns an **`AgentHost`**).
92
92
  3. **`connect()`** to go live; the SDK long-polls the HTTP inbox and routes deliveries into **`handleMessage`**.
93
93
 
94
- Examples in the repo: `examples/agents/*.js` (e.g. coffee, expense, delivery agents).
94
+ Worked examples and the full build path: https://ziggsai.com/docs
95
95
 
96
96
  Point the shared HTTP client at a non-default backend (self-hosted / local) before constructing hosts — no direct `@ziggs-ai/api-client` dependency needed:
97
97
 
@@ -1,5 +1,5 @@
1
1
  import type { AgentConfig, Ctx, Workflow } from './types.js';
2
- import { Agent } from './agent/Agent.js';
2
+ import { Agent, type TickOutput } from './agent/Agent.js';
3
3
  import { ClaudeSdkAgent, type CognitionKind } from './agent/ClaudeSdkAgent.js';
4
4
  import { type BrainSelection } from './brain/index.js';
5
5
  export type { CognitionKind };
@@ -23,8 +23,9 @@ export interface SessionStore {
23
23
  }): Promise<void>;
24
24
  }
25
25
  import { ToolManager } from './tools/ToolManager.js';
26
- import type { ToolDefinition } from './tools/defineTool.js';
26
+ import { type ToolDefinition } from './tools/defineTool.js';
27
27
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
28
+ import { type AgentNeed } from './tools/needs.js';
28
29
  /**
29
30
  * The single fleet-wide default model — the one place the fallback lives.
30
31
  * Change this (or set `LLM_MODEL`) to move the whole fleet; nothing hardcodes a
@@ -76,12 +77,18 @@ export interface AgentHostOptions {
76
77
  systemPrompt?: string;
77
78
  specialization?: string | null;
78
79
  /**
79
- * strings compared exactly against a quest's `match` field.
80
+ * strings compared exactly against a request's `match` field.
80
81
  * Miss → ack with zero LLM. Match → normal wake. `specialization` is also
81
82
  * checked when set.
82
83
  */
83
- questMatchTags?: string[];
84
+ requestMatchTags?: string[];
84
85
  tools?: ToolDefinition[];
86
+ /**
87
+ * Access this agent cannot work without. Enforced at task start:
88
+ * a task arriving with no live grant behind a declared need is failed with the
89
+ * missing access named, before the tick spends anything.
90
+ */
91
+ needs?: AgentNeed[];
85
92
  taskTools?: 'all' | 'none' | string[];
86
93
  /**
87
94
  * Brain selection — preferred over legacy `cognition`.
@@ -142,6 +149,17 @@ export interface AgentHostOptions {
142
149
  cognition: string;
143
150
  metrics: import('./agent/Agent.js').TickOutput['metrics'];
144
151
  }) => void;
152
+ /**
153
+ * Called with everything a wake consists of, immediately before the brain
154
+ * runs. Pair it with {@link AgentHost.replayWake} to re-run that exact wake
155
+ * later against edited agent source — the difference between iterating on a
156
+ * prompt in seconds and standing a whole world back up to see one turn.
157
+ *
158
+ * Emitted for every wake, including ones the brain then declines to spend an
159
+ * LLM call on, because "why did nothing happen?" is the question a recording
160
+ * most often has to answer.
161
+ */
162
+ wakeTap?: (record: WakeRecord) => void;
145
163
  /** Production monitoring ingest. Default on; set false to disable. */
146
164
  telemetryIngest?: boolean;
147
165
  /**
@@ -150,6 +168,28 @@ export interface AgentHostOptions {
150
168
  */
151
169
  inboxCatchUp?: boolean;
152
170
  }
171
+ /**
172
+ * One wake, as the brain received it.
173
+ *
174
+ * These four fields ARE the tick's input (`agent.tick({ event, ctx, state,
175
+ * identity })`) — everything else a tick touches is rebuilt from the agent's own
176
+ * config. So a record is replayable on its own, and a replay that behaves
177
+ * differently differs because the agent changed, not because the input drifted.
178
+ *
179
+ * Plain JSON on purpose: a record is meant to survive being written to a file
180
+ * and read back by another process.
181
+ */
182
+ export interface WakeRecord {
183
+ agentId: string;
184
+ sessionId: string;
185
+ laneKey: string;
186
+ /** The event frame that woke the agent. */
187
+ event: unknown;
188
+ /** FSM context carried into the tick — undefined on a cold lane. */
189
+ ctx?: unknown;
190
+ /** FSM state the lane was parked in — undefined on a cold lane. */
191
+ state?: string;
192
+ }
153
193
  /**
154
194
  * ToolManager is a Map keyed by tool name, so the last registration silently
155
195
  * wins. An agent's own tools register last — which means an `extraTools` entry
@@ -189,6 +229,16 @@ export declare class AgentHost {
189
229
  /** The per-agent inbox long-poll loop — this agent's sole wake source. */
190
230
  private inboxLoop;
191
231
  private inboxLoopEnabled;
232
+ /** Short-lived grant-provider cache for the declared-needs gate. */
233
+ /**
234
+ * Lanes where an unmet-need refusal has already been allowed its one turn to
235
+ * ask for the access (see `_refuseIfNeedsUnmet`). Per lane, not per agent: two
236
+ * hirers each deserve the ask, and neither deserves it twice.
237
+ */
238
+ private askedForAccessLanes;
239
+ /** Tasks left active by the asking turn, failed right after it (per lane). */
240
+ private unmetTasksPendingAsk;
241
+ private grantProviderCache;
192
242
  constructor(options?: AgentHostOptions);
193
243
  private _validate;
194
244
  private _setup;
@@ -245,16 +295,68 @@ export declare class AgentHost {
245
295
  * still gets its turn, because that is an agent with something left to say.
246
296
  */
247
297
  private isSettledTaskEcho;
298
+ /** Providers of this agent's live connection grants. Its own seam, so the gate below is testable without a backend. */
299
+ private _listGrantProviders;
300
+ /**
301
+ * Short TTL on purpose: the gate runs on every wake, and a claim produces
302
+ * several in seconds — but hire comes before grant, so a grant arriving
303
+ * mid-engagement has to take effect without a restart.
304
+ */
305
+ private _grantProvidersCached;
306
+ /**
307
+ * Refuse work this agent has no way to do, before it costs a turn.
308
+ * Returns true when work was refused and the tick must not run.
309
+ *
310
+ * Enforced at wake for an agent holding assigned work, not at hire: hire comes
311
+ * first and the grant second, so a hire-time gate would refuse every honest
312
+ * one. Not limited to the task frame either — a store claim wakes the agent on
313
+ * the agreement first, and a task-only gate let the model take that turn and
314
+ * reason its own way to "I have no grant" (measured on linear-janitor: 8 calls,
315
+ * 114,025 tokens, for what one HTTP call answers).
316
+ *
317
+ * It fails the task rather than skipping quietly, which would leave it active
318
+ * with nothing working it — the same invisible stall from the buyer's side.
319
+ */
320
+ private _refuseIfNeedsUnmet;
321
+ /**
322
+ * Fail whatever the asking turn left active.
323
+ *
324
+ * The one turn granted above runs with the task still active so the agent can
325
+ * ask; the agent's own instructions then fail it. This is the backstop for when
326
+ * they do not: an active task nobody is working is the invisible stall the gate
327
+ * exists to prevent, and it must not be the price of asking.
328
+ */
329
+ private settleUnmetAfterAsk;
330
+ /**
331
+ * Whether this agent holds the tool that turns "I cannot" into "here is what I
332
+ * need". Without it the extra turn buys nothing, so it is not spent.
333
+ */
334
+ private canAskForAccess;
248
335
  private _processEvent;
336
+ /**
337
+ * Re-run one recorded wake against THIS host's agent, and nothing else.
338
+ *
339
+ * The dev loop for a prompt: record a wake off a live run, edit the agent, and
340
+ * see what it does differently in seconds instead of standing a world back up
341
+ * for one turn. The brain, tools and effect handler are the real ones, so the
342
+ * turn is real — its tool calls hit the backend this host is pointed at, which
343
+ * is why it belongs against a disposable world and not a shared one.
344
+ *
345
+ * Deliberately NOT routed through `_processEvent`: everything that method does
346
+ * around the tick is lane bookkeeping — session state, liveness timers, session
347
+ * persistence, ack decisions, failure re-arm — and a replay has no lane to keep.
348
+ * Borrowing that path would make a dev tool able to disturb a live one.
349
+ */
350
+ replayWake(record: WakeRecord): Promise<TickOutput>;
249
351
  /** Arms the lane's liveness timer iff stateName is a parked state declaring timeoutMs. */
250
352
  private _armIfTimedParked;
251
353
  private _resolveOwnAgentId;
252
354
  connectAsync(_timeout?: number): Promise<this>;
253
355
  /**
254
- * plain string compare against {@link AgentHostOptions.questMatchTags}
356
+ * plain string compare against {@link AgentHostOptions.requestMatchTags}
255
357
  * and `specialization`. No match → return (caller acks). Match → normal wake.
256
358
  */
257
- private triageQuestDelivery;
359
+ private triageRequestDelivery;
258
360
  private _bootstrapAssignedActiveTasks;
259
361
  /** replay task-assigned for an existing active task (demo recovery). */
260
362
  wakeTaskAssignment(taskId: string): Promise<void>;
package/dist/AgentHost.js CHANGED
@@ -12,15 +12,17 @@ import { normalizeIncomingEvent } from './ingress/normalizeIncoming.js';
12
12
  import { PromptBuilder } from './runtime/PromptBuilder.js';
13
13
  import { InMemoryStore } from './memory/MemoryStore.js';
14
14
  import { ToolManager } from './tools/ToolManager.js';
15
+ import { toolName } from './tools/defineTool.js';
15
16
  import { PROTOCOL_TOOLS } from './server/tools/tier1/protocolTools.js';
16
17
  import { marketplaceViewTool } from './server/tools/tier2/marketplaceTools.js';
17
18
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
18
19
  import { AnthropicAdapter } from './adapters/AnthropicAdapter.js';
19
- import { ArtifactsClient, ContextReadClient, TelemetryClient, sendChatMessage, } from '@ziggs-ai/api-client';
20
+ import { ArtifactsClient, ConnectionsClient, ContextReadClient, TelemetryClient, sendChatMessage, } from '@ziggs-ai/api-client';
21
+ import { normalizeNeeds, unmetNeeds, unmetNeedsMessage, } from './tools/needs.js';
20
22
  import { TaskService } from './server/tasks/TaskService.js';
21
23
  import { AgreementService } from './server/agreements/AgreementService.js';
22
24
  import { runInboxLoop } from './server/inboxLoop.js';
23
- import { questExactMatch } from './server/questMatch.js';
25
+ import { requestExactMatch } from './server/requestMatch.js';
24
26
  import { SeenMessages } from './server/SeenMessages.js';
25
27
  import { runtimeLog } from './shared/runtimeLog.js';
26
28
  /**
@@ -177,6 +179,16 @@ export class AgentHost {
177
179
  /** The per-agent inbox long-poll loop — this agent's sole wake source. */
178
180
  inboxLoop = null;
179
181
  inboxLoopEnabled;
182
+ /** Short-lived grant-provider cache for the declared-needs gate. */
183
+ /**
184
+ * Lanes where an unmet-need refusal has already been allowed its one turn to
185
+ * ask for the access (see `_refuseIfNeedsUnmet`). Per lane, not per agent: two
186
+ * hirers each deserve the ask, and neither deserves it twice.
187
+ */
188
+ askedForAccessLanes = new Set();
189
+ /** Tasks left active by the asking turn, failed right after it (per lane). */
190
+ unmetTasksPendingAsk = new Map();
191
+ grantProviderCache = null;
180
192
  constructor(options = {}) {
181
193
  const resolved = applyBrainToHostOptions(options);
182
194
  this.options = {
@@ -190,6 +202,11 @@ export class AgentHost {
190
202
  systemPrompt: resolved.systemPrompt,
191
203
  specialization: resolved.specialization ?? null,
192
204
  tools: resolved.tools || [],
205
+ // Copied deliberately: this object is an allow-list, so a field left out
206
+ // here is silently dropped between the manifest and the runtime. That is
207
+ // exactly how `respondsToProposals` reached the store as null for 133
208
+ // agents — the declaration existed and nothing carried it.
209
+ needs: resolved.needs,
193
210
  taskTools: resolved.taskTools,
194
211
  states: resolved.states,
195
212
  initial: resolved.initial,
@@ -200,6 +217,7 @@ export class AgentHost {
200
217
  effectTap: resolved.effectTap,
201
218
  transitionTap: resolved.transitionTap,
202
219
  metricsTap: resolved.metricsTap,
220
+ wakeTap: resolved.wakeTap,
203
221
  inboxCatchUp: resolved.inboxCatchUp,
204
222
  brain: resolved.brain,
205
223
  cognition: resolved.cognition ?? 'fsm',
@@ -209,9 +227,9 @@ export class AgentHost {
209
227
  this.inboxLoopEnabled = options.inboxCatchUp !== false;
210
228
  this.sessionResolver = options.sessionResolver || DEFAULT_SESSION_RESOLVER;
211
229
  // A Claude-brain wake legitimately runs long (multi-turn query, prose
212
- // deliverables) — 120s watchdogged real spec-writing wakes mid-flight,
213
- // which then redelivered and re-ran (dogfood, 2026-07-26). FSM
214
- // turns stay on the tight default.
230
+ // deliverables). At 120s the watchdog cuts real spec-writing wakes
231
+ // mid-flight, which then redeliver and re-run. FSM turns stay on the tight
232
+ // default.
215
233
  this.tickTimeoutMs =
216
234
  options.tickTimeoutMs ??
217
235
  (isClaudeCognition(options.cognition)
@@ -257,6 +275,9 @@ export class AgentHost {
257
275
  };
258
276
  const messageSender = (message, receiverId, sessionId, messageId) => sendChatMessage({
259
277
  chatId: sessionId,
278
+ // Omitted on purpose when the caller has no valid address for THIS
279
+ // room: the backend infers the recipient from the room's own
280
+ // participants.
260
281
  receiverId,
261
282
  text: message,
262
283
  // messageId is the backend's (chatId, messageId) dedup key. Callers
@@ -518,6 +539,151 @@ export class AgentHost {
518
539
  const reported = result.result;
519
540
  return reported !== undefined && reported !== null && reported !== '';
520
541
  }
542
+ /** Providers of this agent's live connection grants. Its own seam, so the gate below is testable without a backend. */
543
+ async _listGrantProviders(operatorKey, agentId) {
544
+ const groups = await new ConnectionsClient(operatorKey, agentId).listForHolder();
545
+ return groups.filter((g) => (g.grants ?? []).length > 0).map((g) => g.provider);
546
+ }
547
+ /**
548
+ * Short TTL on purpose: the gate runs on every wake, and a claim produces
549
+ * several in seconds — but hire comes before grant, so a grant arriving
550
+ * mid-engagement has to take effect without a restart.
551
+ */
552
+ async _grantProvidersCached(operatorKey, agentId) {
553
+ const now = Date.now();
554
+ if (this.grantProviderCache && now - this.grantProviderCache.at < 30_000) {
555
+ return this.grantProviderCache.providers;
556
+ }
557
+ const providers = await this._listGrantProviders(operatorKey, agentId);
558
+ this.grantProviderCache = { at: now, providers };
559
+ return providers;
560
+ }
561
+ /**
562
+ * Refuse work this agent has no way to do, before it costs a turn.
563
+ * Returns true when work was refused and the tick must not run.
564
+ *
565
+ * Enforced at wake for an agent holding assigned work, not at hire: hire comes
566
+ * first and the grant second, so a hire-time gate would refuse every honest
567
+ * one. Not limited to the task frame either — a store claim wakes the agent on
568
+ * the agreement first, and a task-only gate let the model take that turn and
569
+ * reason its own way to "I have no grant" (measured on linear-janitor: 8 calls,
570
+ * 114,025 tokens, for what one HTTP call answers).
571
+ *
572
+ * It fails the task rather than skipping quietly, which would leave it active
573
+ * with nothing working it — the same invisible stall from the buyer's side.
574
+ */
575
+ async _refuseIfNeedsUnmet(_event, _laneKey) {
576
+ const needs = normalizeNeeds(this.options.needs);
577
+ if (!needs.length)
578
+ return false;
579
+ const operatorKey = this.options.operatorKey;
580
+ const agentId = this.options.agentId;
581
+ if (!operatorKey || !agentId || !this.taskService)
582
+ return false;
583
+ let providers;
584
+ try {
585
+ providers = await this._grantProvidersCached(operatorKey, agentId);
586
+ }
587
+ catch (err) {
588
+ // A grants read that failed is not proof the grant is missing, and failing
589
+ // the task on our own outage would be worse than letting the agent try.
590
+ runtimeLog.warn(this.options.name || 'agent', `needs check skipped: ${err?.message ?? err}`);
591
+ return false;
592
+ }
593
+ const unmet = unmetNeeds(needs, providers);
594
+ if (!unmet.length)
595
+ return false;
596
+ // Only work already on this agent's plate is refused. A wake with no assigned
597
+ // task is someone talking to it — an agent that cannot reach Linear can still
598
+ // say so in a sentence, and muting it would be a worse silence.
599
+ let assigned;
600
+ try {
601
+ assigned = await this.taskService.getActiveTasksForAgent(agentId);
602
+ }
603
+ catch (err) {
604
+ runtimeLog.warn(this.options.name || 'agent', `needs check could not read assigned work: ${err?.message ?? err}`);
605
+ return false;
606
+ }
607
+ const mine = assigned.filter((t) => t.taskId && (!t.assigneeId || t.assigneeId === agentId));
608
+ if (!mine.length)
609
+ return false;
610
+ const message = unmetNeedsMessage(unmet);
611
+ /**
612
+ * Ask BEFORE refusing, once per lane, when this agent can ask at all.
613
+ *
614
+ * Order is the whole fix. Failing the task first and then letting the wake
615
+ * run does nothing: the brain declines a wake whose task already reported a
616
+ * result, so the agent never speaks and the buyer is left with "ask the
617
+ * connection owner to issue a grant" for a connection only they can make —
618
+ * and no place in the web app to make it, because the grant dialog lists the
619
+ * workspace's own agents and a fleet-hosted one is not among them.
620
+ *
621
+ * So the first unmet wake on a lane runs with the task still active: the
622
+ * agent's own instructions send the consent card and fail the task. Whatever
623
+ * it leaves active is failed right after the tick ({@link settleUnmetAfterAsk}),
624
+ * so nothing hangs, and every later wake on the lane is refused up front as
625
+ * before — a buyer who ignores the card is not billed for reminders.
626
+ */
627
+ if (this.canAskForAccess() && !this.askedForAccessLanes.has(_laneKey)) {
628
+ this.askedForAccessLanes.add(_laneKey);
629
+ this.unmetTasksPendingAsk.set(_laneKey, mine.map((t) => String(t.taskId)));
630
+ runtimeLog.info(this.options.name || 'agent', `no access for ${unmet.map((n) => n.connection).join('/')} on ${_laneKey} — letting this wake run once so it can ask, then failing whatever it leaves`);
631
+ return false;
632
+ }
633
+ for (const task of mine) {
634
+ const taskId = String(task.taskId);
635
+ runtimeLog.warn(this.options.name || 'agent', `refusing task ${taskId} — ${message}`);
636
+ try {
637
+ await this.taskService.updateState(taskId, 'failed', {
638
+ summary: message,
639
+ status: 'failed',
640
+ });
641
+ }
642
+ catch (err) {
643
+ runtimeLog.warn(this.options.name || 'agent', `could not fail task ${taskId} after refusing it: ${err?.message ?? err}`);
644
+ }
645
+ }
646
+ return true;
647
+ }
648
+ /**
649
+ * Fail whatever the asking turn left active.
650
+ *
651
+ * The one turn granted above runs with the task still active so the agent can
652
+ * ask; the agent's own instructions then fail it. This is the backstop for when
653
+ * they do not: an active task nobody is working is the invisible stall the gate
654
+ * exists to prevent, and it must not be the price of asking.
655
+ */
656
+ async settleUnmetAfterAsk(laneKey) {
657
+ const pending = this.unmetTasksPendingAsk.get(laneKey);
658
+ if (!pending?.length)
659
+ return;
660
+ this.unmetTasksPendingAsk.delete(laneKey);
661
+ if (!this.taskService)
662
+ return;
663
+ for (const taskId of pending) {
664
+ try {
665
+ const active = await this.taskService.getActiveTasksForAgent(this.options.agentId);
666
+ if (!active.some((t) => String(t.taskId) === taskId))
667
+ continue;
668
+ runtimeLog.warn(this.options.name || 'agent', `failing task ${taskId} — it asked for the missing access but left the work open`);
669
+ await this.taskService.updateState(taskId, 'failed', {
670
+ summary: unmetNeedsMessage(normalizeNeeds(this.options.needs)),
671
+ status: 'failed',
672
+ });
673
+ }
674
+ catch (err) {
675
+ runtimeLog.warn(this.options.name || 'agent', `could not settle task ${taskId} after the access ask: ${err?.message ?? err}`);
676
+ }
677
+ }
678
+ }
679
+ /**
680
+ * Whether this agent holds the tool that turns "I cannot" into "here is what I
681
+ * need". Without it the extra turn buys nothing, so it is not spent.
682
+ */
683
+ canAskForAccess() {
684
+ const tools = this.options.tools ?? this.options.services?.tools ?? [];
685
+ return tools.some((t) => toolName(t) === 'connection_request');
686
+ }
521
687
  async _processEvent(event, laneKey) {
522
688
  const session = this.sessions.get(laneKey);
523
689
  if (!session?.sessionId)
@@ -530,6 +696,10 @@ export class AgentHost {
530
696
  runtimeLog.info(this.options.name || 'agent', `skipping wake on ${session.sessionId} — its task already reported a result`);
531
697
  return;
532
698
  }
699
+ // Before the tick spends anything: work this agent declared it cannot do
700
+ // without access it does not hold.
701
+ if (await this._refuseIfNeedsUnmet(event, laneKey))
702
+ return;
533
703
  // Stale timer frames (superseded epoch) are dropped BEFORE touching the
534
704
  // live timer — a pure-stale frame must not reset a freshly armed countdown.
535
705
  const frame = this.stateTimers.stripStale(event, laneKey);
@@ -557,6 +727,24 @@ export class AgentHost {
557
727
  sessionId: session.sessionId,
558
728
  laneKey,
559
729
  };
730
+ // Recorded here rather than after the tick: this is the last point where the
731
+ // input is still exactly what the brain will see, and a wake that goes on to
732
+ // throw is the one most worth replaying.
733
+ if (this.options.wakeTap) {
734
+ try {
735
+ this.options.wakeTap({
736
+ agentId: identity.agentId,
737
+ sessionId: identity.sessionId,
738
+ laneKey,
739
+ event: frame,
740
+ ctx: session.ctx,
741
+ state: session.state,
742
+ });
743
+ }
744
+ catch (err) {
745
+ runtimeLog.warn('AgentHost', `wakeTap failed: ${err?.message || err}`);
746
+ }
747
+ }
560
748
  // The watchdog timer must be cleared once the tick settles — leaving it
561
749
  // armed pinned one live 2-minute timer per tick (and kept short-lived
562
750
  // processes/tests alive until it fired).
@@ -615,6 +803,22 @@ export class AgentHost {
615
803
  // not re-arm, or the lane would runtimeLog.error every timeoutMs forever.
616
804
  if (!result.stateTimeoutUnhandled)
617
805
  this._armIfTimedParked(laneKey, result.state);
806
+ /**
807
+ * Only a wake that actually reached the brain counts as the asking turn.
808
+ *
809
+ * A notice-only wake (`type: 'notification'`) and a rate-gated one skip the
810
+ * LLM entirely, so the agent had no chance to ask — spending the lane's one
811
+ * turn on it, and failing the task straight after, killed the work before
812
+ * the wake that would have done it ever arrived. Hand the turn back instead
813
+ * and let the next real wake use it.
814
+ */
815
+ if ((result.metrics?.llmCalls ?? 0) > 0) {
816
+ await this.settleUnmetAfterAsk(laneKey);
817
+ }
818
+ else if (this.unmetTasksPendingAsk.has(laneKey)) {
819
+ this.unmetTasksPendingAsk.delete(laneKey);
820
+ this.askedForAccessLanes.delete(laneKey);
821
+ }
618
822
  }
619
823
  catch (err) {
620
824
  runtimeLog.warn('AgentHost', `tick failed laneKey=${laneKey}: ${err?.message || err}`);
@@ -642,6 +846,74 @@ export class AgentHost {
642
846
  }
643
847
  finally {
644
848
  clearTimeout(tickWatchdog);
849
+ // Also on the failure path: a tick that threw still must not leave the
850
+ // work it was refused for sitting active.
851
+ await this.settleUnmetAfterAsk(laneKey).catch(() => { });
852
+ }
853
+ }
854
+ /**
855
+ * Re-run one recorded wake against THIS host's agent, and nothing else.
856
+ *
857
+ * The dev loop for a prompt: record a wake off a live run, edit the agent, and
858
+ * see what it does differently in seconds instead of standing a world back up
859
+ * for one turn. The brain, tools and effect handler are the real ones, so the
860
+ * turn is real — its tool calls hit the backend this host is pointed at, which
861
+ * is why it belongs against a disposable world and not a shared one.
862
+ *
863
+ * Deliberately NOT routed through `_processEvent`: everything that method does
864
+ * around the tick is lane bookkeeping — session state, liveness timers, session
865
+ * persistence, ack decisions, failure re-arm — and a replay has no lane to keep.
866
+ * Borrowing that path would make a dev tool able to disturb a live one.
867
+ */
868
+ async replayWake(record) {
869
+ if (!record?.sessionId)
870
+ throw new Error('replayWake: record.sessionId is required');
871
+ const identity = {
872
+ agentId: record.agentId || this.options.agentId,
873
+ sessionId: record.sessionId,
874
+ laneKey: record.laneKey || record.sessionId,
875
+ };
876
+ // Same watchdog as a live wake, for the same reason: an unaborted Claude
877
+ // query outlives the promise that abandoned it and keeps spending.
878
+ const abort = new AbortController();
879
+ let watchdog;
880
+ try {
881
+ const result = await Promise.race([
882
+ this.agent.tick({
883
+ event: record.event,
884
+ ctx: record.ctx,
885
+ state: record.state,
886
+ identity,
887
+ eff: this.eff,
888
+ memory: this.memory,
889
+ transitionTap: this.options.transitionTap,
890
+ signal: abort.signal,
891
+ }),
892
+ new Promise((_, reject) => {
893
+ watchdog = setTimeout(() => {
894
+ abort.abort(new Error('tick timeout'));
895
+ reject(new Error('tick timeout'));
896
+ }, this.tickTimeoutMs);
897
+ }),
898
+ ]);
899
+ if (this.options.metricsTap && result.metrics) {
900
+ try {
901
+ this.options.metricsTap({
902
+ agentId: identity.agentId,
903
+ sessionId: identity.sessionId,
904
+ model: this.options.model ?? 'unknown',
905
+ cognition: this.options.cognition ?? 'fsm',
906
+ metrics: result.metrics,
907
+ });
908
+ }
909
+ catch (err) {
910
+ runtimeLog.warn('AgentHost', `metricsTap failed: ${err?.message || err}`);
911
+ }
912
+ }
913
+ return result;
914
+ }
915
+ finally {
916
+ clearTimeout(watchdog);
645
917
  }
646
918
  }
647
919
  /** Arms the lane's liveness timer iff stateName is a parked state declaring timeoutMs. */
@@ -694,32 +966,32 @@ export class AgentHost {
694
966
  }
695
967
  },
696
968
  // exact-match triage in code — miss acks with 0 tokens.
697
- onQuest: async (quest) => {
698
- await this.triageQuestDelivery(quest);
969
+ onRequest: async (request) => {
970
+ await this.triageRequestDelivery(request);
699
971
  },
700
972
  });
701
973
  }
702
974
  return this;
703
975
  }
704
976
  /**
705
- * plain string compare against {@link AgentHostOptions.questMatchTags}
977
+ * plain string compare against {@link AgentHostOptions.requestMatchTags}
706
978
  * and `specialization`. No match → return (caller acks). Match → normal wake.
707
979
  */
708
- async triageQuestDelivery(quest) {
980
+ async triageRequestDelivery(request) {
709
981
  const tags = [
710
- ...(this.options.questMatchTags ?? []),
982
+ ...(this.options.requestMatchTags ?? []),
711
983
  ...(this.options.specialization ? [this.options.specialization] : []),
712
984
  ];
713
- if (!questExactMatch(quest.match, tags)) {
714
- runtimeLog.debug(this.options.name || 'agent', `quest ${quest.agreementId}: no tag match for ${JSON.stringify(quest.match)} — ack without wake`);
985
+ if (!requestExactMatch(request.match, tags)) {
986
+ runtimeLog.debug(this.options.name || 'agent', `request ${request.agreementId}: no tag match for ${JSON.stringify(request.match)} — ack without wake`);
715
987
  return;
716
988
  }
717
- const needle = quest.match.trim();
718
- const title = quest.title?.trim() || needle;
719
- await this.handleMessage(`New marketplace quest matching your tags (${needle}): ${title}. Claim with agreement_claim if you will take it.`, {
720
- messageId: `quest-wake:${quest.agreementId}:${quest.ts}`,
721
- chatId: `marketplace:${quest.agreementId}`,
722
- agreementId: quest.agreementId,
989
+ const needle = request.match.trim();
990
+ const title = request.title?.trim() || needle;
991
+ await this.handleMessage(`New marketplace request matching your tags (${needle}): ${title}. Claim with agreement_claim if you will take it.`, {
992
+ messageId: `request-wake:${request.agreementId}:${request.ts}`,
993
+ chatId: `marketplace:${request.agreementId}`,
994
+ agreementId: request.agreementId,
723
995
  });
724
996
  }
725
997
  async _bootstrapAssignedActiveTasks() {
@@ -56,12 +56,11 @@ export function resolveZiggsGatewayTarget(config, defaults = {}) {
56
56
  if (!agentId) {
57
57
  throw new Error('ziggs MCP transport requires an agentId to impersonate (X-Agent-Id)');
58
58
  }
59
- // ZIG-1104: explicit overrides still win, but the LAST resort is
60
- // `getBackendUrl()` — the resolution every other SDK call uses — not
61
- // `localhost:3000`. The fleet task sets neither BACKEND_URL nor
62
- // ZIGGS_BACKEND_URL (it sets HTTP_URL), so the old chain silently pointed every
63
- // gateway call at localhost inside the container: the requests never reached the
64
- // backend at all, and the agent reported the server as unreachable.
59
+ // Explicit overrides still win, but the LAST resort is `getBackendUrl()` —
60
+ // the resolution every other SDK call uses — not `localhost:3000`. A deployment
61
+ // that sets none of BACKEND_URL / ZIGGS_BACKEND_URL would otherwise have every
62
+ // gateway call silently point at localhost inside its own container: the
63
+ // requests never reach the backend, and the agent reports it as unreachable.
65
64
  const baseUrl = (config.baseUrl ||
66
65
  process.env.ZIGGS_BACKEND_URL ||
67
66
  process.env.BACKEND_URL ||
@@ -85,12 +84,11 @@ export function resolvePerUserGatewayTarget(config, agentId, userId, onBehalfOfA
85
84
  if (!operatorKey) {
86
85
  throw new Error('ziggs-per-user MCP transport requires an operatorKey (or ZIGGS_OPERATOR_KEY)');
87
86
  }
88
- // ZIG-1104: explicit overrides still win, but the LAST resort is
89
- // `getBackendUrl()` — the resolution every other SDK call uses — not
90
- // `localhost:3000`. The fleet task sets neither BACKEND_URL nor
91
- // ZIGGS_BACKEND_URL (it sets HTTP_URL), so the old chain silently pointed every
92
- // gateway call at localhost inside the container: the requests never reached the
93
- // backend at all, and the agent reported the server as unreachable.
87
+ // Explicit overrides still win, but the LAST resort is `getBackendUrl()` —
88
+ // the resolution every other SDK call uses — not `localhost:3000`. A deployment
89
+ // that sets none of BACKEND_URL / ZIGGS_BACKEND_URL would otherwise have every
90
+ // gateway call silently point at localhost inside its own container: the
91
+ // requests never reach the backend, and the agent reports it as unreachable.
94
92
  const baseUrl = (config.baseUrl ||
95
93
  process.env.ZIGGS_BACKEND_URL ||
96
94
  process.env.BACKEND_URL ||
@@ -52,6 +52,15 @@ export interface ClaudeSdkAgentOptions {
52
52
  * that is all the CLI gives us — no code, no typed error — so keep it narrow
53
53
  * enough that a different failure never claims the retry.
54
54
  */
55
+ /**
56
+ * "I ran out of turns", as Claude Code reports it.
57
+ *
58
+ * Turn exhaustion is not a crash: the wake did real work and stopped at a cap
59
+ * we set. It arrives as a thrown error result with no code and no type, so the
60
+ * message is all there is to match on — kept narrow so no other failure claims
61
+ * the handling.
62
+ */
63
+ export declare function isMaxTurnsError(err: unknown): boolean;
55
64
  export declare function isMissingSessionError(err: unknown): boolean;
56
65
  /**
57
66
  * A Ziggs agent whose brain is the Claude Agent SDK. Same host, same `eff`