@ziggs-ai/agent-sdk 0.11.0 → 0.11.1

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/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 };
@@ -25,6 +25,7 @@ export interface SessionStore {
25
25
  import { ToolManager } from './tools/ToolManager.js';
26
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
@@ -82,6 +83,12 @@ export interface AgentHostOptions {
82
83
  */
83
84
  questMatchTags?: 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,8 @@ 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
+ private grantProviderCache;
192
234
  constructor(options?: AgentHostOptions);
193
235
  private _validate;
194
236
  private _setup;
@@ -245,7 +287,45 @@ export declare class AgentHost {
245
287
  * still gets its turn, because that is an agent with something left to say.
246
288
  */
247
289
  private isSettledTaskEcho;
290
+ /** Providers of this agent's live connection grants. Its own seam, so the gate below is testable without a backend. */
291
+ private _listGrantProviders;
292
+ /**
293
+ * Short TTL on purpose: the gate runs on every wake, and a claim produces
294
+ * several in seconds — but hire comes before grant, so a grant arriving
295
+ * mid-engagement has to take effect without a restart.
296
+ */
297
+ private _grantProvidersCached;
298
+ /**
299
+ * Refuse work this agent has no way to do, before it costs a turn.
300
+ * Returns true when work was refused and the tick must not run.
301
+ *
302
+ * Enforced at wake for an agent holding assigned work, not at hire: hire comes
303
+ * first and the grant second, so a hire-time gate would refuse every honest
304
+ * one. Not limited to the task frame either — a store claim wakes the agent on
305
+ * the agreement first, and a task-only gate let the model take that turn and
306
+ * reason its own way to "I have no grant" (measured on linear-janitor: 8 calls,
307
+ * 114,025 tokens, for what one HTTP call answers).
308
+ *
309
+ * It fails the task rather than skipping quietly, which would leave it active
310
+ * with nothing working it — the same invisible stall from the buyer's side.
311
+ */
312
+ private _refuseIfNeedsUnmet;
248
313
  private _processEvent;
314
+ /**
315
+ * Re-run one recorded wake against THIS host's agent, and nothing else.
316
+ *
317
+ * The dev loop for a prompt: record a wake off a live run, edit the agent, and
318
+ * see what it does differently in seconds instead of standing a world back up
319
+ * for one turn. The brain, tools and effect handler are the real ones, so the
320
+ * turn is real — its tool calls hit the backend this host is pointed at, which
321
+ * is why it belongs against a disposable world and not a shared one.
322
+ *
323
+ * Deliberately NOT routed through `_processEvent`: everything that method does
324
+ * around the tick is lane bookkeeping — session state, liveness timers, session
325
+ * persistence, ack decisions, failure re-arm — and a replay has no lane to keep.
326
+ * Borrowing that path would make a dev tool able to disturb a live one.
327
+ */
328
+ replayWake(record: WakeRecord): Promise<TickOutput>;
249
329
  /** Arms the lane's liveness timer iff stateName is a parked state declaring timeoutMs. */
250
330
  private _armIfTimedParked;
251
331
  private _resolveOwnAgentId;
package/dist/AgentHost.js CHANGED
@@ -16,7 +16,8 @@ import { PROTOCOL_TOOLS } from './server/tools/tier1/protocolTools.js';
16
16
  import { marketplaceViewTool } from './server/tools/tier2/marketplaceTools.js';
17
17
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
18
18
  import { AnthropicAdapter } from './adapters/AnthropicAdapter.js';
19
- import { ArtifactsClient, ContextReadClient, TelemetryClient, sendChatMessage, } from '@ziggs-ai/api-client';
19
+ import { ArtifactsClient, ConnectionsClient, ContextReadClient, TelemetryClient, sendChatMessage, } from '@ziggs-ai/api-client';
20
+ import { normalizeNeeds, unmetNeeds, unmetNeedsMessage, } from './tools/needs.js';
20
21
  import { TaskService } from './server/tasks/TaskService.js';
21
22
  import { AgreementService } from './server/agreements/AgreementService.js';
22
23
  import { runInboxLoop } from './server/inboxLoop.js';
@@ -177,6 +178,8 @@ export class AgentHost {
177
178
  /** The per-agent inbox long-poll loop — this agent's sole wake source. */
178
179
  inboxLoop = null;
179
180
  inboxLoopEnabled;
181
+ /** Short-lived grant-provider cache for the declared-needs gate. */
182
+ grantProviderCache = null;
180
183
  constructor(options = {}) {
181
184
  const resolved = applyBrainToHostOptions(options);
182
185
  this.options = {
@@ -190,6 +193,11 @@ export class AgentHost {
190
193
  systemPrompt: resolved.systemPrompt,
191
194
  specialization: resolved.specialization ?? null,
192
195
  tools: resolved.tools || [],
196
+ // Copied deliberately: this object is an allow-list, so a field left out
197
+ // here is silently dropped between the manifest and the runtime. That is
198
+ // exactly how `respondsToProposals` reached the store as null for 133
199
+ // agents — the declaration existed and nothing carried it.
200
+ needs: resolved.needs,
193
201
  taskTools: resolved.taskTools,
194
202
  states: resolved.states,
195
203
  initial: resolved.initial,
@@ -200,6 +208,7 @@ export class AgentHost {
200
208
  effectTap: resolved.effectTap,
201
209
  transitionTap: resolved.transitionTap,
202
210
  metricsTap: resolved.metricsTap,
211
+ wakeTap: resolved.wakeTap,
203
212
  inboxCatchUp: resolved.inboxCatchUp,
204
213
  brain: resolved.brain,
205
214
  cognition: resolved.cognition ?? 'fsm',
@@ -209,9 +218,9 @@ export class AgentHost {
209
218
  this.inboxLoopEnabled = options.inboxCatchUp !== false;
210
219
  this.sessionResolver = options.sessionResolver || DEFAULT_SESSION_RESOLVER;
211
220
  // 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.
221
+ // deliverables). At 120s the watchdog cuts real spec-writing wakes
222
+ // mid-flight, which then redeliver and re-run. FSM turns stay on the tight
223
+ // default.
215
224
  this.tickTimeoutMs =
216
225
  options.tickTimeoutMs ??
217
226
  (isClaudeCognition(options.cognition)
@@ -518,6 +527,90 @@ export class AgentHost {
518
527
  const reported = result.result;
519
528
  return reported !== undefined && reported !== null && reported !== '';
520
529
  }
530
+ /** Providers of this agent's live connection grants. Its own seam, so the gate below is testable without a backend. */
531
+ async _listGrantProviders(operatorKey, agentId) {
532
+ const groups = await new ConnectionsClient(operatorKey, agentId).listForHolder();
533
+ return groups.filter((g) => (g.grants ?? []).length > 0).map((g) => g.provider);
534
+ }
535
+ /**
536
+ * Short TTL on purpose: the gate runs on every wake, and a claim produces
537
+ * several in seconds — but hire comes before grant, so a grant arriving
538
+ * mid-engagement has to take effect without a restart.
539
+ */
540
+ async _grantProvidersCached(operatorKey, agentId) {
541
+ const now = Date.now();
542
+ if (this.grantProviderCache && now - this.grantProviderCache.at < 30_000) {
543
+ return this.grantProviderCache.providers;
544
+ }
545
+ const providers = await this._listGrantProviders(operatorKey, agentId);
546
+ this.grantProviderCache = { at: now, providers };
547
+ return providers;
548
+ }
549
+ /**
550
+ * Refuse work this agent has no way to do, before it costs a turn.
551
+ * Returns true when work was refused and the tick must not run.
552
+ *
553
+ * Enforced at wake for an agent holding assigned work, not at hire: hire comes
554
+ * first and the grant second, so a hire-time gate would refuse every honest
555
+ * one. Not limited to the task frame either — a store claim wakes the agent on
556
+ * the agreement first, and a task-only gate let the model take that turn and
557
+ * reason its own way to "I have no grant" (measured on linear-janitor: 8 calls,
558
+ * 114,025 tokens, for what one HTTP call answers).
559
+ *
560
+ * It fails the task rather than skipping quietly, which would leave it active
561
+ * with nothing working it — the same invisible stall from the buyer's side.
562
+ */
563
+ async _refuseIfNeedsUnmet(_event, _laneKey) {
564
+ const needs = normalizeNeeds(this.options.needs);
565
+ if (!needs.length)
566
+ return false;
567
+ const operatorKey = this.options.operatorKey;
568
+ const agentId = this.options.agentId;
569
+ if (!operatorKey || !agentId || !this.taskService)
570
+ return false;
571
+ let providers;
572
+ try {
573
+ providers = await this._grantProvidersCached(operatorKey, agentId);
574
+ }
575
+ catch (err) {
576
+ // A grants read that failed is not proof the grant is missing, and failing
577
+ // the task on our own outage would be worse than letting the agent try.
578
+ runtimeLog.warn(this.options.name || 'agent', `needs check skipped: ${err?.message ?? err}`);
579
+ return false;
580
+ }
581
+ const unmet = unmetNeeds(needs, providers);
582
+ if (!unmet.length)
583
+ return false;
584
+ // Only work already on this agent's plate is refused. A wake with no assigned
585
+ // task is someone talking to it — an agent that cannot reach Linear can still
586
+ // say so in a sentence, and muting it would be a worse silence.
587
+ let assigned;
588
+ try {
589
+ assigned = await this.taskService.getActiveTasksForAgent(agentId);
590
+ }
591
+ catch (err) {
592
+ runtimeLog.warn(this.options.name || 'agent', `needs check could not read assigned work: ${err?.message ?? err}`);
593
+ return false;
594
+ }
595
+ const mine = assigned.filter((t) => t.taskId && (!t.assigneeId || t.assigneeId === agentId));
596
+ if (!mine.length)
597
+ return false;
598
+ const message = unmetNeedsMessage(unmet);
599
+ for (const task of mine) {
600
+ const taskId = String(task.taskId);
601
+ runtimeLog.warn(this.options.name || 'agent', `refusing task ${taskId} — ${message}`);
602
+ try {
603
+ await this.taskService.updateState(taskId, 'failed', {
604
+ summary: message,
605
+ status: 'failed',
606
+ });
607
+ }
608
+ catch (err) {
609
+ runtimeLog.warn(this.options.name || 'agent', `could not fail task ${taskId} after refusing it: ${err?.message ?? err}`);
610
+ }
611
+ }
612
+ return true;
613
+ }
521
614
  async _processEvent(event, laneKey) {
522
615
  const session = this.sessions.get(laneKey);
523
616
  if (!session?.sessionId)
@@ -530,6 +623,10 @@ export class AgentHost {
530
623
  runtimeLog.info(this.options.name || 'agent', `skipping wake on ${session.sessionId} — its task already reported a result`);
531
624
  return;
532
625
  }
626
+ // Before the tick spends anything: work this agent declared it cannot do
627
+ // without access it does not hold.
628
+ if (await this._refuseIfNeedsUnmet(event, laneKey))
629
+ return;
533
630
  // Stale timer frames (superseded epoch) are dropped BEFORE touching the
534
631
  // live timer — a pure-stale frame must not reset a freshly armed countdown.
535
632
  const frame = this.stateTimers.stripStale(event, laneKey);
@@ -557,6 +654,24 @@ export class AgentHost {
557
654
  sessionId: session.sessionId,
558
655
  laneKey,
559
656
  };
657
+ // Recorded here rather than after the tick: this is the last point where the
658
+ // input is still exactly what the brain will see, and a wake that goes on to
659
+ // throw is the one most worth replaying.
660
+ if (this.options.wakeTap) {
661
+ try {
662
+ this.options.wakeTap({
663
+ agentId: identity.agentId,
664
+ sessionId: identity.sessionId,
665
+ laneKey,
666
+ event: frame,
667
+ ctx: session.ctx,
668
+ state: session.state,
669
+ });
670
+ }
671
+ catch (err) {
672
+ runtimeLog.warn('AgentHost', `wakeTap failed: ${err?.message || err}`);
673
+ }
674
+ }
560
675
  // The watchdog timer must be cleared once the tick settles — leaving it
561
676
  // armed pinned one live 2-minute timer per tick (and kept short-lived
562
677
  // processes/tests alive until it fired).
@@ -644,6 +759,71 @@ export class AgentHost {
644
759
  clearTimeout(tickWatchdog);
645
760
  }
646
761
  }
762
+ /**
763
+ * Re-run one recorded wake against THIS host's agent, and nothing else.
764
+ *
765
+ * The dev loop for a prompt: record a wake off a live run, edit the agent, and
766
+ * see what it does differently in seconds instead of standing a world back up
767
+ * for one turn. The brain, tools and effect handler are the real ones, so the
768
+ * turn is real — its tool calls hit the backend this host is pointed at, which
769
+ * is why it belongs against a disposable world and not a shared one.
770
+ *
771
+ * Deliberately NOT routed through `_processEvent`: everything that method does
772
+ * around the tick is lane bookkeeping — session state, liveness timers, session
773
+ * persistence, ack decisions, failure re-arm — and a replay has no lane to keep.
774
+ * Borrowing that path would make a dev tool able to disturb a live one.
775
+ */
776
+ async replayWake(record) {
777
+ if (!record?.sessionId)
778
+ throw new Error('replayWake: record.sessionId is required');
779
+ const identity = {
780
+ agentId: record.agentId || this.options.agentId,
781
+ sessionId: record.sessionId,
782
+ laneKey: record.laneKey || record.sessionId,
783
+ };
784
+ // Same watchdog as a live wake, for the same reason: an unaborted Claude
785
+ // query outlives the promise that abandoned it and keeps spending.
786
+ const abort = new AbortController();
787
+ let watchdog;
788
+ try {
789
+ const result = await Promise.race([
790
+ this.agent.tick({
791
+ event: record.event,
792
+ ctx: record.ctx,
793
+ state: record.state,
794
+ identity,
795
+ eff: this.eff,
796
+ memory: this.memory,
797
+ transitionTap: this.options.transitionTap,
798
+ signal: abort.signal,
799
+ }),
800
+ new Promise((_, reject) => {
801
+ watchdog = setTimeout(() => {
802
+ abort.abort(new Error('tick timeout'));
803
+ reject(new Error('tick timeout'));
804
+ }, this.tickTimeoutMs);
805
+ }),
806
+ ]);
807
+ if (this.options.metricsTap && result.metrics) {
808
+ try {
809
+ this.options.metricsTap({
810
+ agentId: identity.agentId,
811
+ sessionId: identity.sessionId,
812
+ model: this.options.model ?? 'unknown',
813
+ cognition: this.options.cognition ?? 'fsm',
814
+ metrics: result.metrics,
815
+ });
816
+ }
817
+ catch (err) {
818
+ runtimeLog.warn('AgentHost', `metricsTap failed: ${err?.message || err}`);
819
+ }
820
+ }
821
+ return result;
822
+ }
823
+ finally {
824
+ clearTimeout(watchdog);
825
+ }
826
+ }
647
827
  /** Arms the lane's liveness timer iff stateName is a parked state declaring timeoutMs. */
648
828
  _armIfTimedParked(laneKey, stateName) {
649
829
  if (!stateName)
@@ -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`