@ziggs-ai/agent-sdk 0.15.0 → 0.17.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 (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +110 -88
  3. package/dist/{AgentHost.d.ts → Agent.d.ts} +66 -27
  4. package/dist/{AgentHost.js → Agent.js} +221 -119
  5. package/dist/adapters/AnthropicAdapter.d.ts +1 -1
  6. package/dist/adapters/AnthropicAdapter.js +1 -1
  7. package/dist/{agent/ClaudeSdkAgent.d.ts → brain/ClaudeBrain.d.ts} +23 -11
  8. package/dist/{agent/ClaudeSdkAgent.js → brain/ClaudeBrain.js} +145 -95
  9. package/dist/{agent/Agent.d.ts → brain/ZiggsBrain.d.ts} +23 -20
  10. package/dist/{agent/Agent.js → brain/ZiggsBrain.js} +12 -11
  11. package/dist/{agent → brain}/claudeCognition.d.ts +1 -1
  12. package/dist/{agent → brain}/claudeCognition.js +1 -1
  13. package/dist/{agent → brain}/claudeSdkProtocol.d.ts +2 -2
  14. package/dist/{agent → brain}/claudeSdkProtocol.js +2 -2
  15. package/dist/{agent → brain}/claudeSdkTools.d.ts +2 -1
  16. package/dist/{agent → brain}/claudeSdkTools.js +9 -65
  17. package/dist/{agent → brain}/hostedAgentConnection.d.ts +1 -1
  18. package/dist/{agent → brain}/hostedAgentConnection.js +3 -3
  19. package/dist/brain/index.d.ts +6 -6
  20. package/dist/brain/index.js +2 -2
  21. package/dist/brain/onBehalfAgreement.d.ts +51 -0
  22. package/dist/brain/onBehalfAgreement.js +99 -0
  23. package/dist/{agent → brain}/toolManagerToSdkMcp.js +10 -1
  24. package/dist/defineAgent.js +4 -4
  25. package/dist/formatters/HistoryFormatter.js +2 -1
  26. package/dist/index.d.ts +30 -31
  27. package/dist/index.js +24 -25
  28. package/dist/{adapters/McpAdapter.d.ts → mcp/createMcpAgent.d.ts} +16 -15
  29. package/dist/{adapters/McpAdapter.js → mcp/createMcpAgent.js} +54 -40
  30. package/dist/memory/MemoryStore.d.ts +18 -1
  31. package/dist/memory/MemoryStore.js +43 -0
  32. package/dist/{server → platform}/ConnectionPool.d.ts +9 -3
  33. package/dist/{server → platform}/ConnectionPool.js +16 -4
  34. package/dist/{server → platform}/EventQueue.d.ts +1 -1
  35. package/dist/{server → platform}/StateTimers.d.ts +1 -1
  36. package/dist/{server → platform}/StateTimers.js +1 -1
  37. package/dist/{server → platform}/ZiggsEffectHandler.d.ts +4 -4
  38. package/dist/{server → platform}/ZiggsEffectHandler.js +2 -2
  39. package/dist/{server → platform}/agreements/AgreementService.d.ts +10 -1
  40. package/dist/{server → platform}/agreements/AgreementService.js +9 -1
  41. package/dist/{server → platform}/inboxLoop.d.ts +34 -1
  42. package/dist/{server → platform}/inboxLoop.js +122 -14
  43. package/dist/{server → platform}/polls/PollKind.d.ts +4 -7
  44. package/dist/{server → platform}/polls/PollKind.js +3 -6
  45. package/dist/{server → platform}/polls/PollRunner.d.ts +5 -6
  46. package/dist/{server → platform}/polls/PollRunner.js +24 -12
  47. package/dist/platform/polls/declaredPollRunner.d.ts +4 -0
  48. package/dist/platform/polls/declaredPollRunner.js +29 -0
  49. package/dist/{server → platform}/tools/index.d.ts +1 -0
  50. package/dist/{server → platform}/tools/index.js +1 -0
  51. package/dist/{server → platform}/tools/tier1/protocolRunner.js +33 -27
  52. package/dist/{server → platform}/tools/tier1/protocolTools.js +2 -7
  53. package/dist/{server → platform}/tools/tier2/mcpConnectionTools.js +6 -3
  54. package/dist/platform/tools/tier2/taskTools.d.ts +3 -0
  55. package/dist/platform/tools/tier2/taskTools.js +6 -0
  56. package/dist/runtime/bootstrapCtx.d.ts +1 -1
  57. package/dist/runtime/bootstrapCtx.js +1 -1
  58. package/dist/runtime/buildOutcome.d.ts +0 -8
  59. package/dist/runtime/buildOutcome.js +20 -6
  60. package/dist/runtime/kickoff.js +1 -1
  61. package/dist/runtime/runTurn.d.ts +1 -1
  62. package/dist/runtime/runTurn.js +18 -18
  63. package/dist/runtime/validateWorkflow.js +1 -1
  64. package/dist/runtime/wakeOutcome.d.ts +27 -0
  65. package/dist/runtime/wakeOutcome.js +43 -0
  66. package/dist/skills/parseSkill.d.ts +7 -2
  67. package/dist/skills/parseSkill.js +22 -5
  68. package/dist/skills/skillReadReference.d.ts +6 -0
  69. package/dist/skills/skillReadReference.js +27 -0
  70. package/dist/tools/needs.js +2 -2
  71. package/dist/tools/recordReport.d.ts +5 -0
  72. package/dist/tools/recordReport.js +5 -0
  73. package/dist/tools/toolCreds.d.ts +1 -1
  74. package/dist/tools/toolCreds.js +1 -1
  75. package/dist/types.d.ts +3 -3
  76. package/package.json +2 -2
  77. package/dist/server/createHealthServer.d.ts +0 -7
  78. package/dist/server/createHealthServer.js +0 -75
  79. package/dist/server/runLauncher.d.ts +0 -22
  80. package/dist/server/runLauncher.js +0 -160
  81. /package/dist/{agent → brain}/toolManagerToSdkMcp.d.ts +0 -0
  82. /package/dist/{server → platform}/EventQueue.js +0 -0
  83. /package/dist/{server → platform}/OutboxBuffer.d.ts +0 -0
  84. /package/dist/{server → platform}/OutboxBuffer.js +0 -0
  85. /package/dist/{server → platform}/SeenMessages.d.ts +0 -0
  86. /package/dist/{server → platform}/SeenMessages.js +0 -0
  87. /package/dist/{server → platform}/proactive/ProactiveTrigger.d.ts +0 -0
  88. /package/dist/{server → platform}/proactive/ProactiveTrigger.js +0 -0
  89. /package/dist/{server → platform}/requestMatch.d.ts +0 -0
  90. /package/dist/{server → platform}/requestMatch.js +0 -0
  91. /package/dist/{server → platform}/tasks/TaskService.d.ts +0 -0
  92. /package/dist/{server → platform}/tasks/TaskService.js +0 -0
  93. /package/dist/{server → platform}/tasks/index.d.ts +0 -0
  94. /package/dist/{server → platform}/tasks/index.js +0 -0
  95. /package/dist/{server → platform}/telemetryIngest.d.ts +0 -0
  96. /package/dist/{server → platform}/telemetryIngest.js +0 -0
  97. /package/dist/{server → platform}/tools/tier1/protocolRunner.d.ts +0 -0
  98. /package/dist/{server → platform}/tools/tier1/protocolTools.d.ts +0 -0
  99. /package/dist/{server → platform}/tools/tier2/artifactTools.d.ts +0 -0
  100. /package/dist/{server → platform}/tools/tier2/artifactTools.js +0 -0
  101. /package/dist/{server → platform}/tools/tier2/chatTools.d.ts +0 -0
  102. /package/dist/{server → platform}/tools/tier2/chatTools.js +0 -0
  103. /package/dist/{server → platform}/tools/tier2/connectionTools.d.ts +0 -0
  104. /package/dist/{server → platform}/tools/tier2/connectionTools.js +0 -0
  105. /package/dist/{server → platform}/tools/tier2/contextTools.d.ts +0 -0
  106. /package/dist/{server → platform}/tools/tier2/contextTools.js +0 -0
  107. /package/dist/{server → platform}/tools/tier2/discoveryTools.d.ts +0 -0
  108. /package/dist/{server → platform}/tools/tier2/discoveryTools.js +0 -0
  109. /package/dist/{server → platform}/tools/tier2/grantsTools.d.ts +0 -0
  110. /package/dist/{server → platform}/tools/tier2/grantsTools.js +0 -0
  111. /package/dist/{server → platform}/tools/tier2/introductionTools.d.ts +0 -0
  112. /package/dist/{server → platform}/tools/tier2/introductionTools.js +0 -0
  113. /package/dist/{server → platform}/tools/tier2/linkTools.d.ts +0 -0
  114. /package/dist/{server → platform}/tools/tier2/linkTools.js +0 -0
  115. /package/dist/{server → platform}/tools/tier2/marketplaceTools.d.ts +0 -0
  116. /package/dist/{server → platform}/tools/tier2/marketplaceTools.js +0 -0
  117. /package/dist/{server → platform}/tools/tier2/mcpConnectionTools.d.ts +0 -0
  118. /package/dist/{server → platform}/tools/tier2/paymentTools.d.ts +0 -0
  119. /package/dist/{server → platform}/tools/tier2/paymentTools.js +0 -0
@@ -7,7 +7,7 @@ interface AdapterOptions {
7
7
  /**
8
8
  * Anthropic adapter satisfying the same LlmAdapter contract as OpenAIAdapter:
9
9
  * OpenAI-wire-shaped LlmMessage[] in, { content, tool_calls, usage } out.
10
- * Selected by AgentHost when the configured model id starts with "claude".
10
+ * Selected by Agent when the configured model id starts with "claude".
11
11
  */
12
12
  export declare class AnthropicAdapter {
13
13
  private client;
@@ -8,7 +8,7 @@ const MAX_COMPLETION_TOKENS = 16000;
8
8
  /**
9
9
  * Anthropic adapter satisfying the same LlmAdapter contract as OpenAIAdapter:
10
10
  * OpenAI-wire-shaped LlmMessage[] in, { content, tool_calls, usage } out.
11
- * Selected by AgentHost when the configured model id starts with "claude".
11
+ * Selected by Agent when the configured model id starts with "claude".
12
12
  */
13
13
  export class AnthropicAdapter {
14
14
  client;
@@ -1,8 +1,9 @@
1
+ import type { Brain } from './index.js';
1
2
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
3
  import type { Workflow } from '../types.js';
3
4
  import type { ToolManager } from '../tools/ToolManager.js';
4
5
  import { type RawEvent } from '../runtime/buildOutcome.js';
5
- import type { TickInput, TickOutput } from './Agent.js';
6
+ import type { TickInput, TickOutput } from './ZiggsBrain.js';
6
7
  /**
7
8
  * status notices must not spend a Claude turn answering
8
9
  * nobody. Real work wakes arrive as chat `message`, synthetic `task_result`
@@ -21,9 +22,15 @@ import type { TickInput, TickOutput } from './Agent.js';
21
22
  * message still lands in chat history and arrives as context on the next
22
23
  * real wake — skipped here is the LLM turn, not the information.
23
24
  */
25
+ /**
26
+ * Store publish chats the agent in a room named `probe_<agentId>_<ts>`.
27
+ * That check is "are you connected and can you reply", not a hired job —
28
+ * spending a model turn on it is how a long skill prompt 504s the gate.
29
+ */
30
+ export declare function isCapabilityProbeSession(sessionId: string | undefined | null): boolean;
24
31
  export declare function isNoticeOnlyWake(event: RawEvent | undefined, ownAgentId?: string | null): boolean;
25
32
  export type CognitionKind = 'fsm' | 'claude-sdk';
26
- export interface ClaudeSdkAgentOptions {
33
+ export interface ClaudeBrainOptions {
27
34
  workflow: Workflow;
28
35
  toolManager: ToolManager;
29
36
  /** Store-facing one-liner. Used as the role block only when `systemPrompt` is absent. */
@@ -63,15 +70,18 @@ export interface ClaudeSdkAgentOptions {
63
70
  export declare function isMaxTurnsError(err: unknown): boolean;
64
71
  export declare function isMissingSessionError(err: unknown): boolean;
65
72
  /**
66
- * A Ziggs agent whose brain is the Claude Agent SDK. Same host, same `eff`
67
- * side channel, same tool surface as the FSM `Agent` — the ONLY difference is
73
+ * A brain driven by the Claude Agent SDK. Same body, same `eff`
74
+ * side channel, same tool surface as `ZiggsBrain` — the ONLY difference is
68
75
  * the loop: instead of the FSM + `runTurn`, a wake drives a headless Claude
69
76
  * `query()` (see "host is the product; the brain is how it decides").
70
77
  */
71
- export declare class ClaudeSdkAgent {
78
+ export declare class ClaudeBrain implements Brain {
79
+ readonly kind: "claude";
72
80
  workflow: Workflow;
73
81
  toolManager: ToolManager;
74
82
  private rolePrompt;
83
+ /** Store one-liner — used when the publish probe asks who we are. */
84
+ private listingBlurb;
75
85
  private specialization;
76
86
  private model;
77
87
  private anthropicKey;
@@ -81,12 +91,14 @@ export declare class ClaudeSdkAgent {
81
91
  private statelessPerWake;
82
92
  private queryFn;
83
93
  /**
84
- * #7 — tool set is fixed at host construction; rebuilding the
85
- * in-process MCP server (+ zod schema tree) every wake was pure waste.
86
- * A mutable holder supplies per-wake context without closing over `tick`.
94
+ * Per-lane tool context. One agent object serves every room, and a
95
+ * wake's query can still be running when another lane starts. A single
96
+ * `current` field meant B's room/sender/write keys overwrote A's
97
+ * mid-query. Wakes on one lane stay serialized; one server per lane is
98
+ * enough, and getContext closes over that lane's key.
87
99
  */
88
- private toolContextHolder;
89
- private sdkMcpServer;
90
- constructor(opts: ClaudeSdkAgentOptions);
100
+ private toolContextByLane;
101
+ private sdkMcpByLane;
102
+ constructor(opts: ClaudeBrainOptions);
91
103
  tick({ event, ctx, state, identity, eff, signal }: TickInput): Promise<TickOutput>;
92
104
  }
@@ -1,3 +1,4 @@
1
+ import { isAgreementId } from '@ziggs-ai/api-client';
1
2
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
3
  import { isMessageAddressedElsewhere, outcomeFromEvent } from '../runtime/buildOutcome.js';
3
4
  import { getBatchEvents } from '../context/batch.js';
@@ -8,6 +9,7 @@ import { buildZiggsSdkMcpServer } from './toolManagerToSdkMcp.js';
8
9
  import { claudeQueryOptions, buildClaudeEventPrompt, summarizeContext, extractAssistantText, } from './claudeCognition.js';
9
10
  import { assertHostedAgentZiggsTools } from './hostedAgentConnection.js';
10
11
  import { runtimeLog } from '../shared/runtimeLog.js';
12
+ import { resolveOnBehalfAgreementId, } from './onBehalfAgreement.js';
11
13
  /**
12
14
  * status notices must not spend a Claude turn answering
13
15
  * nobody. Real work wakes arrive as chat `message`, synthetic `task_result`
@@ -26,6 +28,14 @@ import { runtimeLog } from '../shared/runtimeLog.js';
26
28
  * message still lands in chat history and arrives as context on the next
27
29
  * real wake — skipped here is the LLM turn, not the information.
28
30
  */
31
+ /**
32
+ * Store publish chats the agent in a room named `probe_<agentId>_<ts>`.
33
+ * That check is "are you connected and can you reply", not a hired job —
34
+ * spending a model turn on it is how a long skill prompt 504s the gate.
35
+ */
36
+ export function isCapabilityProbeSession(sessionId) {
37
+ return typeof sessionId === 'string' && sessionId.startsWith('probe_');
38
+ }
29
39
  export function isNoticeOnlyWake(event, ownAgentId) {
30
40
  const events = getBatchEvents(event);
31
41
  if (events.length === 0)
@@ -164,61 +174,18 @@ function extractMetrics(message) {
164
174
  };
165
175
  }
166
176
  /**
167
- * Resolve the agreement this wake acts under, for a delegated per-user MCP
168
- * call. Without it the gateway resolves the connection owner from
169
- * `senderId` — which, under a delegation, is the delegating agent (who owns no
170
- * connection) → a spurious NO_USER_CONNECTION even though the payer IS connected.
171
- *
172
- * The id must be found on ANY wake the worker might fetch on, not just
173
- * `task_result` events. In practice the worker often fetches while reacting to
174
- * the proposal-approved / agreement-active lifecycle events, which carry the id
175
- * at the event top level rather than under `result`. Reading only
176
- * `result.agreementId` (the original wiring) left those wakes with no
177
- * agreement → the false "authorize your connection" prompt. Resolve from every
178
- * reliable source instead:
179
- * 1. task_result events → `result.agreementId`
180
- * 2. agreement-lifecycle events → top-level `agreementId`
181
- * 3. task lanes → `sessionId` IS the agreement id
182
- * 4. fallback → the single active agreement in context this agent provides
183
- */
184
- function resolveOnBehalfAgreementId(rawEvent, contextSnap, agentId, sessionId) {
185
- const str = (v) => typeof v === 'string' && v ? v : undefined;
186
- const fromResult = str(rawEvent?.result?.agreementId);
187
- if (fromResult)
188
- return fromResult;
189
- const fromEvent = str(rawEvent?.agreementId);
190
- if (fromEvent)
191
- return fromEvent;
192
- if (typeof sessionId === 'string' && sessionId.startsWith('agr_'))
193
- return sessionId;
194
- // Fallback: exactly one active agreement in context where this agent is the
195
- // provider. Guarded to a single match so we never send the wrong agreement.
196
- const ags = Array.isArray(contextSnap?.agreements)
197
- ? contextSnap.agreements
198
- : [];
199
- const provides = ags.filter((a) => {
200
- const provider = a?.parties?.provider;
201
- const providerIds = [provider?.actor, a?.providerId, a?.providerAgentId, a?.executorId];
202
- const isProvider = providerIds.some((x) => x === agentId);
203
- const status = a?.status != null ? String(a.status).toLowerCase() : '';
204
- const active = !status || status === 'active' || status === 'approved';
205
- return isProvider && active;
206
- });
207
- if (provides.length === 1) {
208
- return str(provides[0].agreementId) ?? str(provides[0].id) ?? str(provides[0]._id);
209
- }
210
- return undefined;
211
- }
212
- /**
213
- * A Ziggs agent whose brain is the Claude Agent SDK. Same host, same `eff`
214
- * side channel, same tool surface as the FSM `Agent` — the ONLY difference is
177
+ * A brain driven by the Claude Agent SDK. Same body, same `eff`
178
+ * side channel, same tool surface as `ZiggsBrain` — the ONLY difference is
215
179
  * the loop: instead of the FSM + `runTurn`, a wake drives a headless Claude
216
180
  * `query()` (see "host is the product; the brain is how it decides").
217
181
  */
218
- export class ClaudeSdkAgent {
182
+ export class ClaudeBrain {
183
+ kind = 'claude';
219
184
  workflow;
220
185
  toolManager;
221
186
  rolePrompt;
187
+ /** Store one-liner — used when the publish probe asks who we are. */
188
+ listingBlurb;
222
189
  specialization;
223
190
  model;
224
191
  anthropicKey;
@@ -228,25 +195,28 @@ export class ClaudeSdkAgent {
228
195
  statelessPerWake;
229
196
  queryFn;
230
197
  /**
231
- * #7 — tool set is fixed at host construction; rebuilding the
232
- * in-process MCP server (+ zod schema tree) every wake was pure waste.
233
- * A mutable holder supplies per-wake context without closing over `tick`.
198
+ * Per-lane tool context. One agent object serves every room, and a
199
+ * wake's query can still be running when another lane starts. A single
200
+ * `current` field meant B's room/sender/write keys overwrote A's
201
+ * mid-query. Wakes on one lane stay serialized; one server per lane is
202
+ * enough, and getContext closes over that lane's key.
234
203
  */
235
- toolContextHolder = { current: null };
236
- sdkMcpServer = null;
204
+ toolContextByLane = new Map();
205
+ sdkMcpByLane = new Map();
237
206
  constructor(opts) {
238
207
  if (!opts.workflow?.states)
239
- throw new Error('ClaudeSdkAgent: workflow with states is required');
208
+ throw new Error('ClaudeBrain: workflow with states is required');
240
209
  if (!opts.toolManager)
241
- throw new Error('ClaudeSdkAgent: toolManager is required');
210
+ throw new Error('ClaudeBrain: toolManager is required');
242
211
  if (!opts.anthropicKey)
243
- throw new Error('ClaudeSdkAgent: anthropicKey is required');
212
+ throw new Error('ClaudeBrain: anthropicKey is required');
244
213
  this.workflow = opts.workflow;
245
214
  this.toolManager = opts.toolManager;
246
215
  // the prompt and the store blurb are two different strings. Agents
247
216
  // written before the split still carry the prompt in `description`, so fall
248
217
  // back to it rather than silently dropping their role.
249
218
  this.rolePrompt = opts.systemPrompt || opts.description;
219
+ this.listingBlurb = opts.description;
250
220
  this.specialization = opts.specialization ?? null;
251
221
  this.model = opts.model;
252
222
  this.anthropicKey = opts.anthropicKey;
@@ -258,18 +228,15 @@ export class ClaudeSdkAgent {
258
228
  }
259
229
  async tick({ event, ctx, state, identity, eff, signal }) {
260
230
  if (!identity?.sessionId)
261
- throw new Error('ClaudeSdkAgent.tick: identity.sessionId is required');
231
+ throw new Error('ClaudeBrain.tick: identity.sessionId is required');
262
232
  if (!eff)
263
- throw new Error('ClaudeSdkAgent.tick: eff is required');
233
+ throw new Error('ClaudeBrain.tick: eff is required');
264
234
  const rawEvent = event;
265
235
  const nextCtx = bootstrapCtx(identity, ctx);
266
236
  nextCtx.lastOutcome = outcomeFromEvent(rawEvent ?? null, identity.agentId);
267
- const now = Date.now();
268
- const recentDeliveries = (nextCtx.recentDeliveries ?? []).filter((t) => now - t < WAKE_WINDOW_MS);
269
- if (recentDeliveries.length >= MAX_WAKES_PER_WINDOW) {
270
- runtimeLog.warn('ClaudeSdkAgent', `wake-rate breaker tripped: ${recentDeliveries.length} wakes in ${WAKE_WINDOW_MS / 60_000}min ` +
271
- `session=${identity.sessionId} agent=${this.agentId} — skipping LLM for this wake`);
272
- nextCtx.recentDeliveries = recentDeliveries;
237
+ if (isNoticeOnlyWake(rawEvent, identity.agentId)) {
238
+ runtimeLog.info('ClaudeBrain', `notice-only wake — skipping LLM session=${identity.sessionId} agent=${this.agentId} ` +
239
+ `type=${rawEvent?.type ?? 'unknown'}`);
273
240
  return {
274
241
  ctx: nextCtx,
275
242
  state: state ?? DEFAULT_STATE,
@@ -278,10 +245,42 @@ export class ClaudeSdkAgent {
278
245
  metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
279
246
  };
280
247
  }
281
- nextCtx.recentDeliveries = [...recentDeliveries, now];
282
- if (isNoticeOnlyWake(rawEvent, identity.agentId)) {
283
- runtimeLog.info('ClaudeSdkAgent', `notice-only wake — skipping LLM session=${identity.sessionId} agent=${this.agentId} ` +
284
- `type=${rawEvent?.type ?? 'unknown'}`);
248
+ // Store publish must hear a reply even when leftover mail already tripped
249
+ // the wake-rate breaker. The breaker used to sit above this and park the
250
+ // probe with no send-message — Register then died on ALB 504.
251
+ if (nextCtx.lastOutcome?.kind === 'message-received' &&
252
+ isCapabilityProbeSession(identity.sessionId)) {
253
+ const text = `I received your message. ${this.listingBlurb.trim().split(/\r?\n/)[0]?.trim() || 'I am online.'}`;
254
+ const writeKeys = createDeliveryWriteKeys(identity.laneKey, rawEvent);
255
+ const senderId = rawEvent?.senderId != null && String(rawEvent.senderId).trim() !== ''
256
+ ? String(rawEvent.senderId)
257
+ : undefined;
258
+ await eff({
259
+ kind: 'send-message',
260
+ sessionId: identity.sessionId,
261
+ ...(senderId ? { receiverId: senderId } : {}),
262
+ text,
263
+ messageId: writeKeys.messageKey({
264
+ text,
265
+ sessionId: identity.sessionId,
266
+ receiverId: senderId,
267
+ }),
268
+ });
269
+ runtimeLog.info('ClaudeBrain', `capability-probe reply — skipping LLM session=${identity.sessionId} agent=${this.agentId}`);
270
+ return {
271
+ ctx: nextCtx,
272
+ state: state ?? DEFAULT_STATE,
273
+ parked: true,
274
+ stateTimeoutUnhandled: false,
275
+ metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
276
+ };
277
+ }
278
+ const now = Date.now();
279
+ const recentDeliveries = (nextCtx.recentDeliveries ?? []).filter((t) => now - t < WAKE_WINDOW_MS);
280
+ if (recentDeliveries.length >= MAX_WAKES_PER_WINDOW) {
281
+ runtimeLog.warn('ClaudeBrain', `wake-rate breaker tripped: ${recentDeliveries.length} wakes in ${WAKE_WINDOW_MS / 60_000}min ` +
282
+ `session=${identity.sessionId} agent=${this.agentId} — skipping LLM for this wake`);
283
+ nextCtx.recentDeliveries = recentDeliveries;
285
284
  return {
286
285
  ctx: nextCtx,
287
286
  state: state ?? DEFAULT_STATE,
@@ -290,6 +289,7 @@ export class ClaudeSdkAgent {
290
289
  metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
291
290
  };
292
291
  }
292
+ nextCtx.recentDeliveries = [...recentDeliveries, now];
293
293
  let contextSnap = null;
294
294
  try {
295
295
  // #5: pre-read is for wake gating + summarizeContext (a count) +
@@ -302,7 +302,7 @@ export class ClaudeSdkAgent {
302
302
  });
303
303
  }
304
304
  catch (err) {
305
- runtimeLog.warn('ClaudeSdkAgent', `read-context failed session=${identity.sessionId}: ${err.message}`);
305
+ runtimeLog.warn('ClaudeBrain', `read-context failed session=${identity.sessionId}: ${err.message}`);
306
306
  }
307
307
  // Same guard as the FSM's runTurn: a message wake for a chat the backend
308
308
  // no longer resolves (deleted / not visible) has no world to act in —
@@ -314,17 +314,15 @@ export class ClaudeSdkAgent {
314
314
  // signal. Loud-log that mis-key; message wakes still skip below.
315
315
  if (contextSnap?.chatMissing === true) {
316
316
  const sessionId = identity.sessionId;
317
- const looksLikeBareAgreement = typeof sessionId === 'string' &&
318
- sessionId.startsWith('agr_') &&
319
- !sessionId.startsWith('agrn-');
317
+ const looksLikeBareAgreement = isAgreementId(sessionId);
320
318
  if (looksLikeBareAgreement) {
321
- runtimeLog.warn('ClaudeSdkAgent', `chatMissing on non-chat sessionId=${sessionId} agent=${this.agentId} ` +
319
+ runtimeLog.warn('ClaudeBrain', `chatMissing on non-chat sessionId=${sessionId} agent=${this.agentId} ` +
322
320
  `outcome=${nextCtx.lastOutcome?.kind ?? 'none'} — lane was keyed by ` +
323
321
  `agreementId instead of the agreement's origin/notification chat; ` +
324
322
  `wake continues with an empty snapshot`);
325
323
  }
326
324
  if (nextCtx.lastOutcome?.kind === 'message-received') {
327
- runtimeLog.warn('ClaudeSdkAgent', `chat ${sessionId} not found on backend — skipping message wake agent=${this.agentId}`);
325
+ runtimeLog.warn('ClaudeBrain', `chat ${sessionId} not found on backend — skipping message wake agent=${this.agentId}`);
328
326
  return {
329
327
  ctx: nextCtx,
330
328
  state: state ?? DEFAULT_STATE,
@@ -359,26 +357,33 @@ export class ClaudeSdkAgent {
359
357
  // MCP call resolve the originating end-user (payer) rather than the
360
358
  // delegating agent (senderId). Resolved from every reliable wire source,
361
359
  // not just task_result events — see resolveOnBehalfAgreementId.
362
- onBehalfOfAgreementId: resolveOnBehalfAgreementId(rawEvent, contextSnap, this.agentId, identity.sessionId),
360
+ // Skip payer-only subs named on the worker result; bind to the
361
+ // provider hire (see onBehalfAgreement.ts).
362
+ onBehalfOfAgreementId: resolveOnBehalfAgreementId(rawEvent, Array.isArray(contextSnap?.agreements)
363
+ ? contextSnap.agreements
364
+ : null, this.agentId, identity.sessionId),
363
365
  runId,
364
366
  writeKeys,
365
367
  };
366
- this.toolContextHolder.current = toolContext;
367
- if (!this.sdkMcpServer) {
368
+ const lane = identity.laneKey;
369
+ this.toolContextByLane.set(lane, toolContext);
370
+ let sdkMcpServer = this.sdkMcpByLane.get(lane);
371
+ if (!sdkMcpServer) {
368
372
  const availableTools = await this.toolManager.getAvailableTools();
369
- this.sdkMcpServer = buildZiggsSdkMcpServer(availableTools, () => {
370
- const ctx = this.toolContextHolder.current;
373
+ sdkMcpServer = buildZiggsSdkMcpServer(availableTools, () => {
374
+ const ctx = this.toolContextByLane.get(lane);
371
375
  if (!ctx) {
372
- throw new Error('ClaudeSdkAgent: tool context missing for in-process MCP call');
376
+ throw new Error(`ClaudeBrain: tool context missing for lane ${lane}`);
373
377
  }
374
378
  return ctx;
375
379
  }, {
376
380
  name: 'ziggs',
377
381
  instructions: 'In-process Ziggs tools backed by api-client. No external MCP process.',
378
382
  });
383
+ this.sdkMcpByLane.set(lane, sdkMcpServer);
379
384
  }
380
385
  const prompt = buildClaudeEventPrompt(rawEvent, summarizeContext(contextSnap));
381
- const mcpServers = { ziggs: this.sdkMcpServer };
386
+ const mcpServers = { ziggs: sdkMcpServer };
382
387
  assertHostedAgentZiggsTools(mcpServers);
383
388
  const queryOptions = claudeQueryOptions({
384
389
  systemPrompt,
@@ -389,7 +394,7 @@ export class ClaudeSdkAgent {
389
394
  stderr: (data) => {
390
395
  const line = data.trim();
391
396
  if (line)
392
- runtimeLog.warn('ClaudeSdkAgent', `stderr session=${identity.sessionId}: ${line}`);
397
+ runtimeLog.warn('ClaudeBrain', `stderr session=${identity.sessionId}: ${line}`);
393
398
  },
394
399
  });
395
400
  let metrics = { llmCalls: 0, tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } };
@@ -424,7 +429,7 @@ export class ClaudeSdkAgent {
424
429
  try {
425
430
  for await (const message of query) {
426
431
  if (signal?.aborted) {
427
- runtimeLog.warn('ClaudeSdkAgent', `host watchdog aborted wake session=${identity.sessionId} agent=${this.agentId} — stopping query`);
432
+ runtimeLog.warn('ClaudeBrain', `host watchdog aborted wake session=${identity.sessionId} agent=${this.agentId} — stopping query`);
428
433
  break;
429
434
  }
430
435
  if ('session_id' in message && typeof message.session_id === 'string') {
@@ -450,14 +455,14 @@ export class ClaudeSdkAgent {
450
455
  // reorder key assignment against other writes of this wake.
451
456
  const idempotencyKey = writeKeys.artifactKey({ kind: 'thought', text });
452
457
  void eff({ kind: 'record-event', sessionId: identity.sessionId, entry: { kind: 'thought', text }, idempotencyKey })
453
- .catch((err) => runtimeLog.warn('ClaudeSdkAgent', `record thought failed session=${identity.sessionId}: ${err.message}`));
458
+ .catch((err) => runtimeLog.warn('ClaudeBrain', `record thought failed session=${identity.sessionId}: ${err.message}`));
454
459
  }
455
460
  }
456
461
  const m = extractMetrics(message);
457
462
  if (m)
458
463
  metrics = m;
459
464
  if (message.type === 'result' && message.subtype !== 'success') {
460
- runtimeLog.warn('ClaudeSdkAgent', `query ended without success subtype=${message.subtype} session=${identity.sessionId}`);
465
+ runtimeLog.warn('ClaudeBrain', `query ended without success subtype=${message.subtype} session=${identity.sessionId}`);
461
466
  }
462
467
  // Runaway guard: abort before an unbounded loop drains the
463
468
  // key. Breaking the for-await calls the generator's return(),
@@ -475,7 +480,7 @@ export class ClaudeSdkAgent {
475
480
  }
476
481
  if (spentInput > MAX_TASK_INPUT_TOKENS) {
477
482
  abortedBy = 'input-budget';
478
- runtimeLog.warn('ClaudeSdkAgent', `input-token budget exceeded (~${Math.round(spentInput)} cost-weighted > ${MAX_TASK_INPUT_TOKENS}) session=${identity.sessionId} agent=${this.agentId} — aborting query to cap spend`);
483
+ runtimeLog.warn('ClaudeBrain', `input-token budget exceeded (~${Math.round(spentInput)} cost-weighted > ${MAX_TASK_INPUT_TOKENS}) session=${identity.sessionId} agent=${this.agentId} — aborting query to cap spend`);
479
484
  break;
480
485
  }
481
486
  }
@@ -492,6 +497,44 @@ export class ClaudeSdkAgent {
492
497
  * why. The lane's history carries the reason instead, so a wedged-task
493
498
  * detector has something to read.
494
499
  */
500
+ /**
501
+ * Close the task this wake was woken for, when a guard stopped the wake
502
+ * before the agent could close it itself.
503
+ *
504
+ * The note above records the reason where a detector could read it. Nothing
505
+ * reads it, and the hirer reads none of it: the task stays `active` with no
506
+ * result, the wake returns parked like any other, its inbox item is acked,
507
+ * and the engagement looks alive forever. Measured on production — a
508
+ * connector agent whose tool schema costs ~20-45k input per wake spent the
509
+ * session budget across four wakes on one lane and its task sat untouched
510
+ * for as long as anyone cared to watch. The buyer had a greeting, a hire,
511
+ * and silence.
512
+ *
513
+ * So the guard now says the same thing the prompt tells every agent to say
514
+ * when it cannot do the work: fail the task, with the reason, because a
515
+ * visible failure the counterparty can re-route beats a silent drop.
516
+ * Best-effort — a wake already cut short must not throw on the way out.
517
+ */
518
+ const failTaskCutShort = async (reason) => {
519
+ const taskId = nextCtx.activeTaskId;
520
+ if (!taskId)
521
+ return;
522
+ await eff({
523
+ kind: 'tool-call',
524
+ sessionId: identity.sessionId,
525
+ name: 'task_set_result',
526
+ args: {
527
+ taskId,
528
+ state: 'failed',
529
+ result: {
530
+ summary: `Stopped before finishing: ${reason}. Nothing was filed for this task.`,
531
+ status: 'failed',
532
+ },
533
+ },
534
+ }).catch((err) => runtimeLog.warn('ClaudeBrain', `could not fail task ${taskId} after a cut-short wake ` +
535
+ `session=${identity.sessionId}: ${err.message}`));
536
+ runtimeLog.warn('ClaudeBrain', `failed task ${taskId} because the wake was cut short session=${identity.sessionId} agent=${this.agentId}`);
537
+ };
495
538
  const noteWakeCutShort = async (reason) => {
496
539
  const text = `Wake stopped early: ${reason}. Work in progress was NOT filed — no ` +
497
540
  `artifact was recorded and no task was closed by this wake.`;
@@ -500,7 +543,7 @@ export class ClaudeSdkAgent {
500
543
  sessionId: identity.sessionId,
501
544
  entry: { kind: 'thought', text },
502
545
  idempotencyKey: writeKeys.artifactKey({ kind: 'thought', text }),
503
- }).catch((err) => runtimeLog.warn('ClaudeSdkAgent', `record abort note failed session=${identity.sessionId}: ${err.message}`));
546
+ }).catch((err) => runtimeLog.warn('ClaudeBrain', `record abort note failed session=${identity.sessionId}: ${err.message}`));
504
547
  };
505
548
  try {
506
549
  await consumeQuery(resumeId);
@@ -510,7 +553,12 @@ export class ClaudeSdkAgent {
510
553
  // needs its own note before the throw propagates.
511
554
  if (isMaxTurnsError(err)) {
512
555
  abortedBy = 'max-turns';
513
- await noteWakeCutShort(`the turn cap (${this.maxTurns}) was reached`);
556
+ // Same reasoning as the spend guard below: a cap that stops the work
557
+ // has to close the work, or the hirer is left watching an active task
558
+ // nobody is on.
559
+ const reason = `the turn cap (${this.maxTurns}) was reached`;
560
+ await noteWakeCutShort(reason);
561
+ await failTaskCutShort(reason);
514
562
  }
515
563
  // the resumed transcript is gone. Claude Code stores transcripts
516
564
  // on the container's own disk while the session id outlives it — persisted
@@ -525,10 +573,10 @@ export class ClaudeSdkAgent {
525
573
  // attempt — a mid-wake failure must keep propagating, since its partial
526
574
  // writes are already out.
527
575
  if (!isMissingSessionError(err) || !resumeId) {
528
- runtimeLog.warn('ClaudeSdkAgent', `query failed session=${identity.sessionId}: ${err.message}`);
576
+ runtimeLog.warn('ClaudeBrain', `query failed session=${identity.sessionId}: ${err.message}`);
529
577
  throw err;
530
578
  }
531
- runtimeLog.info('ClaudeSdkAgent', `resumed Claude session ${resumeId} no longer exists (container replaced?) ` +
579
+ runtimeLog.info('ClaudeBrain', `resumed Claude session ${resumeId} no longer exists (container replaced?) ` +
532
580
  `session=${identity.sessionId} agent=${this.agentId} — retrying once on a fresh session`);
533
581
  nextCtx.claudeSessionId = undefined;
534
582
  sessionId = undefined;
@@ -542,7 +590,7 @@ export class ClaudeSdkAgent {
542
590
  await consumeQuery(undefined);
543
591
  }
544
592
  catch (retryErr) {
545
- runtimeLog.warn('ClaudeSdkAgent', `query failed session=${identity.sessionId}: ${retryErr.message}`);
593
+ runtimeLog.warn('ClaudeBrain', `query failed session=${identity.sessionId}: ${retryErr.message}`);
546
594
  throw retryErr;
547
595
  }
548
596
  }
@@ -564,13 +612,15 @@ export class ClaudeSdkAgent {
564
612
  // re-reads the world from scratch and runs out of budget at the same step,
565
613
  // and a standing job cannot advance.
566
614
  if (nextCtx.claudeSessionId && peakTurnInput > MAX_SESSION_RESUME_INPUT_TOKENS) {
567
- runtimeLog.info('ClaudeSdkAgent', `resetting Claude session (transcript ~${peakTurnInput} > ${MAX_SESSION_RESUME_INPUT_TOKENS}) ` +
615
+ runtimeLog.info('ClaudeBrain', `resetting Claude session (transcript ~${peakTurnInput} > ${MAX_SESSION_RESUME_INPUT_TOKENS}) ` +
568
616
  `session=${identity.sessionId} agent=${this.agentId} — next wake starts fresh`);
569
617
  nextCtx.claudeSessionId = undefined;
570
618
  }
571
619
  if (abortedBy === 'input-budget') {
572
- await noteWakeCutShort(`the per-wake spend guard cut this run off (~${Math.round(spentInput)} ` +
573
- `cost-weighted input tokens > ${MAX_TASK_INPUT_TOKENS})`);
620
+ const reason = `the per-wake spend guard cut this run off (~${Math.round(spentInput)} ` +
621
+ `cost-weighted input tokens > ${MAX_TASK_INPUT_TOKENS})`;
622
+ await noteWakeCutShort(reason);
623
+ await failTaskCutShort(reason);
574
624
  }
575
625
  return {
576
626
  ctx: nextCtx,
@@ -1,9 +1,10 @@
1
+ import type { Brain } from './index.js';
1
2
  import type { Ctx, Identity, Workflow, EffectHandler } from '../types.js';
2
3
  import { type TransitionEvent } from '../runtime/AgentMachine.js';
3
4
  import { PromptBuilder } from '../runtime/PromptBuilder.js';
4
5
  import type { MemoryStore } from '../memory/MemoryStore.js';
5
6
  import type { ToolManager } from '../tools/ToolManager.js';
6
- export interface AgentOptions {
7
+ export interface ZiggsBrainOptions {
7
8
  workflow: Workflow;
8
9
  toolManager: ToolManager;
9
10
  promptBuilder?: PromptBuilder;
@@ -11,26 +12,27 @@ export interface AgentOptions {
11
12
  export interface TickInput {
12
13
  /** Wire event (already normalized: a `message` or `task_result`-style envelope). */
13
14
  event: unknown;
14
- /** Server-provided per-session state. Omit on the first tick — a fresh ctx is constructed. */
15
+ /** Agent-provided per-session state. Omit on the first tick — a fresh ctx is constructed. */
15
16
  ctx?: Ctx;
16
- /** Server-provided workflow state name. Omit on the first tick — defaults to `workflow.initial`. */
17
+ /** Agent-provided workflow state name. Omit on the first tick — defaults to `workflow.initial`. */
17
18
  state?: string;
18
- /** Who am I and what session am I running in. The Agent carries this through Ctx. */
19
+ /** Who am I and what session am I running in. The ZiggsBrain carries this through Ctx. */
19
20
  identity: Identity;
20
21
  /** The single side channel for I/O — LLM, context, tools, send, record. */
21
22
  eff: EffectHandler;
22
23
  /**
23
24
  * Agent-private long-term memory. Operator-provided (default
24
- * `InMemoryStore` when omitted by `AgentHost`). Key shape is the agent's
25
- * choice — `counterparty:<id>`, `agreement:<id>:notes`, etc.
25
+ * `InMemoryStore` when omitted by `Agent`). The agent already scopes
26
+ * this to the hire the wake is inside; key shape inside that section is
27
+ * the agent's choice — `counterparty:<id>`, `agreement:<id>:notes`, etc.
26
28
  */
27
29
  memory?: MemoryStore;
28
30
  /** Optional tap for FSM transition events. */
29
31
  transitionTap?: (event: TransitionEvent) => void;
30
32
  /**
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
33
+ * Aborted by the agent when the tick watchdog fires. A brain driving an
34
+ * external loop (the Claude ZiggsBrain SDK query) MUST stop consuming it on
35
+ * abort — the agent's Promise.race only abandons the tick promise, so
34
36
  * without this the query keeps running as a zombie: burning tokens and
35
37
  * racing its own redelivered retry (observed as duplicate kickoffs and a
36
38
  * 363k-token wake, 2026-07-26).
@@ -38,13 +40,13 @@ export interface TickInput {
38
40
  signal?: AbortSignal;
39
41
  }
40
42
  export interface TickOutput {
41
- /** Updated ctx — Server persists this against the session. */
43
+ /** Updated ctx — Agent persists this against the session. */
42
44
  ctx: Ctx;
43
- /** Workflow state name after the tick — Server persists this against the session. */
45
+ /** Workflow state name after the tick — Agent persists this against the session. */
44
46
  state: string;
45
47
  /** Whether the machine settled at a parked state. */
46
48
  parked: boolean;
47
- /** True when a state-timeout outcome matched no transition this tick — the host must NOT re-arm. */
49
+ /** True when a state-timeout outcome matched no transition this tick — the agent must NOT re-arm. */
48
50
  stateTimeoutUnhandled: boolean;
49
51
  /**
50
52
  * Accumulated LLM call count and token usage for this tick.
@@ -64,10 +66,10 @@ export interface TickOutput {
64
66
  };
65
67
  }
66
68
  /**
67
- * Pure agent: workflow definition + cognition machinery. No I/O, no transport,
68
- * no services. The Agent's only side channel is the `eff` handler it receives
69
- * on each `tick`. Server-side state (sessions, lanes, queue, outbox) lives in
70
- * `AgentHost` — not here.
69
+ * Workflow brain: workflow definition + cognition machinery. No I/O, no transport,
70
+ * no services. The brain's only side channel is the `eff` handler it receives
71
+ * on each `tick`. Body state (sessions, lanes, queue, outbox) lives in
72
+ * `Agent` — not here.
71
73
  *
72
74
  * The contract:
73
75
  *
@@ -75,13 +77,14 @@ export interface TickOutput {
75
77
  * `{ ctx, state, parked }`
76
78
  *
77
79
  * Reads top-to-bottom like a synchronous function. Every `await eff(...)`
78
- * inside the cognition core is a yield to the Server, which performs the
79
- * impure operation and resumes the Agent with the result.
80
+ * inside the cognition core is a yield to the body, which performs the
81
+ * impure operation and resumes the ZiggsBrain with the result.
80
82
  */
81
- export declare class Agent {
83
+ export declare class ZiggsBrain implements Brain {
84
+ readonly kind: "ziggs";
82
85
  workflow: Workflow;
83
86
  toolManager: ToolManager;
84
87
  promptBuilder: PromptBuilder;
85
- constructor({ workflow, toolManager, promptBuilder }: AgentOptions);
88
+ constructor({ workflow, toolManager, promptBuilder }: ZiggsBrainOptions);
86
89
  tick({ event, ctx, state, identity, eff, memory, transitionTap }: TickInput): Promise<TickOutput>;
87
90
  }