@ziggs-ai/agent-sdk 0.10.8 → 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.
Files changed (104) hide show
  1. package/CHANGELOG.md +11 -11
  2. package/README.md +1 -1
  3. package/dist/AgentHost.d.ts +96 -16
  4. package/dist/AgentHost.js +205 -25
  5. package/dist/adapters/AnthropicAdapter.js +1 -1
  6. package/dist/adapters/McpAdapter.d.ts +10 -10
  7. package/dist/adapters/McpAdapter.js +31 -21
  8. package/dist/adapters/OpenAIAdapter.js +1 -1
  9. package/dist/agent/Agent.d.ts +2 -2
  10. package/dist/agent/ClaudeSdkAgent.d.ts +15 -6
  11. package/dist/agent/ClaudeSdkAgent.js +149 -48
  12. package/dist/agent/claudeCognition.d.ts +3 -3
  13. package/dist/agent/claudeCognition.js +7 -7
  14. package/dist/agent/claudeSdkTools.d.ts +3 -3
  15. package/dist/agent/claudeSdkTools.js +34 -15
  16. package/dist/agent/hostedAgentConnection.d.ts +1 -1
  17. package/dist/agent/hostedAgentConnection.js +1 -1
  18. package/dist/agent/toolManagerToSdkMcp.d.ts +1 -1
  19. package/dist/agent/toolManagerToSdkMcp.js +2 -2
  20. package/dist/context/applyEffects.js +1 -1
  21. package/dist/defineAgent.js +33 -4
  22. package/dist/formatters/HistoryFormatter.js +2 -2
  23. package/dist/index.d.ts +4 -2
  24. package/dist/index.js +8 -5
  25. package/dist/ingress/normalizeIncoming.d.ts +1 -1
  26. package/dist/ingress/normalizeIncoming.js +7 -7
  27. package/dist/pricing/fleetDefaults.d.ts +3 -3
  28. package/dist/pricing/fleetDefaults.js +3 -3
  29. package/dist/pricing/fleetEvalFree.d.ts +1 -1
  30. package/dist/pricing/fleetEvalFree.js +1 -1
  31. package/dist/pricing/fleetFreeTierA.gen.d.ts +1 -1
  32. package/dist/pricing/fleetFreeTierA.gen.js +1 -1
  33. package/dist/pricing/fleetTierByAgentId.gen.d.ts +2 -2
  34. package/dist/pricing/fleetTierByAgentId.gen.js +9 -2
  35. package/dist/runtime/AgentMachine.d.ts +1 -1
  36. package/dist/runtime/AgentMachine.js +8 -8
  37. package/dist/runtime/PromptBuilder.d.ts +2 -2
  38. package/dist/runtime/PromptBuilder.js +3 -3
  39. package/dist/runtime/bootstrapCtx.d.ts +1 -1
  40. package/dist/runtime/bootstrapCtx.js +2 -2
  41. package/dist/runtime/buildOutcome.d.ts +1 -1
  42. package/dist/runtime/buildOutcome.js +4 -4
  43. package/dist/runtime/defaults.d.ts +5 -5
  44. package/dist/runtime/defaults.js +4 -4
  45. package/dist/runtime/deliveryWriteKeys.d.ts +1 -1
  46. package/dist/runtime/deliveryWriteKeys.js +3 -3
  47. package/dist/runtime/kickoff.d.ts +3 -3
  48. package/dist/runtime/kickoff.js +3 -3
  49. package/dist/runtime/runMechanical.d.ts +1 -1
  50. package/dist/runtime/runMechanical.js +1 -1
  51. package/dist/runtime/runTurn.d.ts +1 -1
  52. package/dist/runtime/runTurn.js +10 -10
  53. package/dist/runtime/taskBookkeeping.d.ts +3 -3
  54. package/dist/runtime/taskBookkeeping.js +3 -3
  55. package/dist/runtime/taskIntake.js +2 -2
  56. package/dist/server/ConnectionPool.js +1 -1
  57. package/dist/server/EventQueue.d.ts +1 -1
  58. package/dist/server/EventQueue.js +1 -1
  59. package/dist/server/SeenMessages.d.ts +2 -2
  60. package/dist/server/SeenMessages.js +2 -2
  61. package/dist/server/ZiggsEffectHandler.d.ts +2 -2
  62. package/dist/server/ZiggsEffectHandler.js +18 -9
  63. package/dist/server/agreements/AgreementService.d.ts +3 -8
  64. package/dist/server/agreements/AgreementService.js +4 -7
  65. package/dist/server/inboxLoop.d.ts +5 -5
  66. package/dist/server/inboxLoop.js +27 -11
  67. package/dist/server/polls/PollRunner.d.ts +36 -1
  68. package/dist/server/polls/PollRunner.js +42 -2
  69. package/dist/server/questMatch.d.ts +1 -1
  70. package/dist/server/questMatch.js +1 -1
  71. package/dist/server/runLauncher.js +3 -3
  72. package/dist/server/tasks/TaskService.d.ts +5 -5
  73. package/dist/server/tools/index.d.ts +1 -0
  74. package/dist/server/tools/index.js +7 -6
  75. package/dist/server/tools/tier1/protocolRunner.d.ts +1 -1
  76. package/dist/server/tools/tier1/protocolRunner.js +19 -19
  77. package/dist/server/tools/tier1/protocolTools.js +4 -4
  78. package/dist/server/tools/tier2/artifactTools.js +2 -2
  79. package/dist/server/tools/tier2/chatTools.js +2 -2
  80. package/dist/server/tools/tier2/connectionTools.js +3 -3
  81. package/dist/server/tools/tier2/contextTools.js +1 -1
  82. package/dist/server/tools/tier2/discoveryTools.js +2 -2
  83. package/dist/server/tools/tier2/grantsTools.js +1 -1
  84. package/dist/server/tools/tier2/linkTools.js +3 -3
  85. package/dist/server/tools/tier2/marketplaceTools.js +1 -1
  86. package/dist/server/tools/tier2/mcpConnectionTools.d.ts +4 -0
  87. package/dist/server/tools/tier2/mcpConnectionTools.js +136 -0
  88. package/dist/server/tools/tier2/paymentTools.js +1 -1
  89. package/dist/shared/operatorKeyClaims.d.ts +2 -2
  90. package/dist/shared/operatorKeyClaims.js +1 -1
  91. package/dist/tasks/protocolRegistry.d.ts +1 -1
  92. package/dist/tasks/protocolRegistry.js +1 -1
  93. package/dist/tools/ToolManager.d.ts +1 -1
  94. package/dist/tools/ToolManager.js +3 -2
  95. package/dist/tools/capabilityAdapter.d.ts +1 -1
  96. package/dist/tools/capabilityAdapter.js +3 -3
  97. package/dist/tools/defineTool.d.ts +7 -0
  98. package/dist/tools/defineTool.js +13 -1
  99. package/dist/tools/needs.d.ts +54 -0
  100. package/dist/tools/needs.js +84 -0
  101. package/dist/tools/recordReport.d.ts +3 -3
  102. package/dist/tools/recordReport.js +3 -3
  103. package/dist/types.d.ts +28 -18
  104. package/package.json +3 -3
@@ -1,11 +1,11 @@
1
1
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
2
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
3
3
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
- import { ConnectionsClient } from '@ziggs-ai/api-client';
4
+ import { ConnectionsClient, getBackendUrl } from '@ziggs-ai/api-client';
5
5
  import { AgentHost } from '../AgentHost.js';
6
6
  import { thinkingDefaults } from '../runtime/defaults.js';
7
7
  /**
8
- * Typed failure from the Ziggs MCP gateway (ZIG-702). `code` is the stable
8
+ * Typed failure from the Ziggs MCP gateway. `code` is the stable
9
9
  * machine-readable reason — e.g. `GRANT_REVOKED`, `GRANT_EXPIRED`,
10
10
  * `GRANT_HOLDER_MISMATCH`, `GRANT_CONNECTION_MISMATCH` — so agent code can
11
11
  * branch ("revoked → tell the user and stop retrying" vs "network blip →
@@ -56,10 +56,15 @@ 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
+ // 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.
59
64
  const baseUrl = (config.baseUrl ||
60
- process.env.BACKEND_URL ||
61
65
  process.env.ZIGGS_BACKEND_URL ||
62
- 'http://localhost:3000').replace(/\/$/, '');
66
+ process.env.BACKEND_URL ||
67
+ getBackendUrl()).replace(/\/$/, '');
63
68
  return {
64
69
  url: new URL(`${baseUrl}/connections/${encodeURIComponent(config.connectionId)}/mcp`),
65
70
  headers: {
@@ -70,7 +75,7 @@ export function resolveZiggsGatewayTarget(config, defaults = {}) {
70
75
  };
71
76
  }
72
77
  /**
73
- * Resolve the per-user gateway URL + headers (ZIG-771). No connectionId/grantId —
78
+ * Resolve the per-user gateway URL + headers. No connectionId/grantId —
74
79
  * the gateway resolves the invoking user's own connection + grant from
75
80
  * `x-on-behalf-of-user` + the impersonated agent.
76
81
  */
@@ -79,17 +84,22 @@ export function resolvePerUserGatewayTarget(config, agentId, userId, onBehalfOfA
79
84
  if (!operatorKey) {
80
85
  throw new Error('ziggs-per-user MCP transport requires an operatorKey (or ZIGGS_OPERATOR_KEY)');
81
86
  }
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.
82
92
  const baseUrl = (config.baseUrl ||
83
- process.env.BACKEND_URL ||
84
93
  process.env.ZIGGS_BACKEND_URL ||
85
- 'http://localhost:3000').replace(/\/$/, '');
94
+ process.env.BACKEND_URL ||
95
+ getBackendUrl()).replace(/\/$/, '');
86
96
  return {
87
97
  url: new URL(`${baseUrl}/connections/mcp/relay`),
88
98
  headers: {
89
99
  authorization: `Bearer ${operatorKey}`,
90
100
  'x-agent-id': agentId,
91
101
  'x-on-behalf-of-user': userId,
92
- // ZIG-831: when this call runs under a delegation, userId is the delegating
102
+ // when this call runs under a delegation, userId is the delegating
93
103
  // agent (senderId), not the end-user who owns the connection. The gateway
94
104
  // uses this agreement to resolve the delegation root's user.
95
105
  ...(onBehalfOfAgreementId ? { 'x-on-behalf-of-agreement': onBehalfOfAgreementId } : {}),
@@ -103,7 +113,7 @@ const CONTROL_TOOL_RESERVE = 4;
103
113
  /**
104
114
  * How many MCP tools may be bound to the LLM, given the provider cap and the
105
115
  * number of extra (non-MCP) tools the agent also carries. Always >= 1 so a
106
- * pathological config still binds something rather than nothing. ZIG-759.
116
+ * pathological config still binds something rather than nothing.
107
117
  */
108
118
  export function mcpToolBudget(maxTools, extraToolCount) {
109
119
  return Math.max(1, maxTools - CONTROL_TOOL_RESERVE - extraToolCount);
@@ -218,7 +228,7 @@ function buildDefaultWorkflow(description, mcpTools) {
218
228
  * that full block rides every turn. Keep the lead — what the tool does + how to
219
229
  * call it — and drop the embedded reference; agents fetch detail via dedicated
220
230
  * schema/help tools at call time. Tunable; big enough to preserve real guidance.
221
- * ZIG-759.
231
+ *.
222
232
  */
223
233
  export const MAX_TOOL_DESC_CHARS = 2000;
224
234
  /** Truncate one description at a sentence/newline boundary near the cap. */
@@ -230,7 +240,7 @@ export function trimDescription(desc) {
230
240
  const head = boundary > MAX_TOOL_DESC_CHARS * 0.6 ? slice.slice(0, boundary + 1) : slice;
231
241
  return `${head.trimEnd()} […description truncated; use the server's schema/help tools for full detail]`;
232
242
  }
233
- /** Trim oversized descriptions across the tool set (cost control, ZIG-759). */
243
+ /** Trim oversized descriptions across the tool set (cost control). */
234
244
  function trimToolDescriptions(tools, agentId) {
235
245
  let trimmedCount = 0;
236
246
  let savedChars = 0;
@@ -243,14 +253,14 @@ function trimToolDescriptions(tools, agentId) {
243
253
  return { ...t, description: nextDesc };
244
254
  });
245
255
  if (trimmedCount > 0) {
246
- console.warn(`[McpAdapter] ${agentId}: trimmed ${trimmedCount} oversized tool description(s) to ${MAX_TOOL_DESC_CHARS} chars, saving ~${Math.round(savedChars / 4 / 1000)}K tokens/turn. (ZIG-759)`);
256
+ console.warn(`[McpAdapter] ${agentId}: trimmed ${trimmedCount} oversized tool description(s) to ${MAX_TOOL_DESC_CHARS} chars, saving ~${Math.round(savedChars / 4 / 1000)}K tokens/turn.`);
247
257
  }
248
258
  return out;
249
259
  }
250
260
  /**
251
261
  * Keep only tools whose name matches one of the allowlist substrings
252
262
  * (case-insensitive). No allowlist → pass through unchanged. Applied before the
253
- * maxTools cap so a targeted agent binds exactly the tools it needs. ZIG-759.
263
+ * maxTools cap so a targeted agent binds exactly the tools it needs.
254
264
  */
255
265
  function filterToolsByAllowlist(tools, allowedTools, agentId) {
256
266
  if (!allowedTools || allowedTools.length === 0)
@@ -263,7 +273,7 @@ function filterToolsByAllowlist(tools, allowedTools, agentId) {
263
273
  /**
264
274
  * Cap the tool set bound to the LLM at the provider limit (OpenAI 128) —
265
275
  * truncate and log rather than crash — and trim oversized descriptions so the
266
- * bound schema doesn't ship a manual every turn. ZIG-759 Phase 1. Shared by the
276
+ * bound schema doesn't ship a manual every turn. Phase 1. Shared by the
267
277
  * boot-time (single-client) and per-user paths. When `allowedTools` is set the
268
278
  * server's tools are narrowed by name first, so the cap rarely bites.
269
279
  */
@@ -273,7 +283,7 @@ function capTools(tools, agentId, maxTools, extraToolCount, allowedTools) {
273
283
  const budget = mcpToolBudget(maxTools, extraToolCount);
274
284
  if (trimmed.length <= budget)
275
285
  return trimmed;
276
- console.warn(`[McpAdapter] ${agentId}: MCP server exposes ${trimmed.length} tools, over the ${maxTools}-tool LLM cap; binding the first ${budget}, dropping ${trimmed.length - budget}. (ZIG-759 Phase 2 will select per query.)`);
286
+ console.warn(`[McpAdapter] ${agentId}: MCP server exposes ${trimmed.length} tools, over the ${maxTools}-tool LLM cap; binding the first ${budget}, dropping ${trimmed.length - budget}.`);
277
287
  return trimmed.slice(0, budget);
278
288
  }
279
289
  /**
@@ -311,7 +321,7 @@ function capTools(tools, agentId, maxTools, extraToolCount, allowedTools) {
311
321
  */
312
322
  export async function createMcpAgent(options) {
313
323
  const { transport: transportConfig, extraTools = [], workflow, maxTools = DEFAULT_MAX_TOOLS, allowedTools, toolSchema, ...hostOptions } = options;
314
- // ── Per-user path (ZIG-771): no boot connection; one MCP client per invoking
324
+ // ── Per-user path: no boot connection; one MCP client per invoking
315
325
  // user, each carrying that user's on-behalf-of header for an isolated session.
316
326
  if (transportConfig.type === 'ziggs-per-user') {
317
327
  const agentId = transportConfig.agentId || hostOptions.agentId;
@@ -351,12 +361,12 @@ export async function createMcpAgent(options) {
351
361
  // When the invoking user hasn't connected this server, open a connection
352
362
  // request targeting THEM (X-On-Behalf-Of-User) so the consent prompt lands
353
363
  // in their inbox. The backend dedups pending requests, so retrying every
354
- // turn is safe. ZIG-771.
364
+ // turn is safe.
355
365
  const backendBase = (transportConfig.baseUrl ||
356
366
  process.env.BACKEND_URL ||
357
367
  process.env.ZIGGS_BACKEND_URL ||
358
368
  'http://localhost:3000').replace(/\/$/, '');
359
- // App URL for the user-facing connect link (ZIG-790): api.ziggsai.com → ziggsai.com.
369
+ // App URL for the user-facing connect link: api.ziggsai.com → ziggsai.com.
360
370
  const appUrl = backendBase.includes('localhost')
361
371
  ? 'http://localhost:1234'
362
372
  : backendBase.replace(/\/\/api\./, '//');
@@ -364,7 +374,7 @@ export async function createMcpAgent(options) {
364
374
  ? new URL(transportConfig.serverUrl).host
365
375
  : 'the external service';
366
376
  // Open a connection request for this user; returns its id so the caller can
367
- // deep-link the user straight to it on the Connections page (ZIG-790).
377
+ // deep-link the user straight to it on the Connections page.
368
378
  const triggerConnectionRequest = async (userId) => {
369
379
  if (!transportConfig.serverUrl)
370
380
  return null;
@@ -390,11 +400,11 @@ export async function createMcpAgent(options) {
390
400
  if (!userId) {
391
401
  throw new Error('per-user MCP tool call requires an invoking user (context.senderId is missing)');
392
402
  }
393
- // ZIG-831: for a delegated wake, senderId is the delegating agent, not the
403
+ // for a delegated wake, senderId is the delegating agent, not the
394
404
  // connection-owning end-user; pass the agreement so the gateway resolves
395
405
  // the delegation root's user.
396
406
  const onBehalfOfAgreementId = typeof context.onBehalfOfAgreementId === 'string' ? context.onBehalfOfAgreementId : undefined;
397
- // ZIG-832 (B): per-user resolution is intermittently flaky even when the
407
+ // (B): per-user resolution is intermittently flaky even when the
398
408
  // connection is valid and resolvable — the same on-behalf call fails with
399
409
  // NO_USER_CONNECTION on one wake and succeeds on the next. clientFor never
400
410
  // caches a failed connect, so each attempt re-resolves from scratch. Retry
@@ -137,7 +137,7 @@ export class OpenAIAdapter {
137
137
  stream: true,
138
138
  // Without this, a streamed completion never returns a usage block — the
139
139
  // streamed reply (often the largest output) would bill tokens that are
140
- // counted nowhere (ZIG-813). The usage arrives on a final chunk whose
140
+ // counted nowhere. The usage arrives on a final chunk whose
141
141
  // `choices` is empty.
142
142
  stream_options: { include_usage: true },
143
143
  max_completion_tokens: maxCompletionTokensForModel(this.model),
@@ -25,7 +25,7 @@ export interface TickInput {
25
25
  * choice — `counterparty:<id>`, `agreement:<id>:notes`, etc.
26
26
  */
27
27
  memory?: MemoryStore;
28
- /** Optional tap for FSM transition events (ZIG-442). */
28
+ /** Optional tap for FSM transition events. */
29
29
  transitionTap?: (event: TransitionEvent) => void;
30
30
  /**
31
31
  * Aborted by the host when the tick watchdog fires. A brain driving an
@@ -49,7 +49,7 @@ export interface TickOutput {
49
49
  /**
50
50
  * Accumulated LLM call count and token usage for this tick.
51
51
  * `cacheRead` / `cacheCreation` are populated by the Claude brain (its usage
52
- * exposes cache traffic, which is the bulk of real cost — ZIG-759/813); the
52
+ * exposes cache traffic, which is the bulk of real cost/813); the
53
53
  * FSM path leaves them undefined. `total` includes cache tokens when present.
54
54
  */
55
55
  metrics: {
@@ -4,7 +4,7 @@ import type { ToolManager } from '../tools/ToolManager.js';
4
4
  import { type RawEvent } from '../runtime/buildOutcome.js';
5
5
  import type { TickInput, TickOutput } from './Agent.js';
6
6
  /**
7
- * ZIG-1035 / ZIG-1052: status notices must not spend a Claude turn answering
7
+ * status notices must not spend a Claude turn answering
8
8
  * nobody. Real work wakes arrive as chat `message`, synthetic `task_result`
9
9
  * from `wakeTaskAssignment`, or `agreement_lifecycle` (hire / proposal
10
10
  * resolution — the FSM kickoffs on the same event; Claude must act too).
@@ -28,7 +28,7 @@ export interface ClaudeSdkAgentOptions {
28
28
  toolManager: ToolManager;
29
29
  /** Store-facing one-liner. Used as the role block only when `systemPrompt` is absent. */
30
30
  description: string;
31
- /** The agent's system prompt (ZIG-1080). Preferred over `description` for the role block. */
31
+ /** The agent's system prompt. Preferred over `description` for the role block. */
32
32
  systemPrompt?: string;
33
33
  specialization?: string | null;
34
34
  model: string;
@@ -36,7 +36,7 @@ export interface ClaudeSdkAgentOptions {
36
36
  operatorKey: string;
37
37
  agentId: string;
38
38
  maxTurns?: number;
39
- /** ZIG-860: when true, never resume the prior Claude session — each wake is a
39
+ /** when true, never resume the prior Claude session — each wake is a
40
40
  * fresh transcript. Keeps per-wake input flat on step-driven orchestrators
41
41
  * whose wakes already carry their state. */
42
42
  statelessPerWake?: boolean;
@@ -44,7 +44,7 @@ export interface ClaudeSdkAgentOptions {
44
44
  queryFn?: typeof query;
45
45
  }
46
46
  /**
47
- * ZIG-1191 — "the transcript you asked me to resume is not here".
47
+ * "the transcript you asked me to resume is not here".
48
48
  *
49
49
  * Claude Code answers a `resume` of an unknown id with this, and it is the only
50
50
  * failure that a fresh session reliably fixes: the id is valid-looking but its
@@ -52,12 +52,21 @@ 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`
58
67
  * side channel, same tool surface as the FSM `Agent` — the ONLY difference is
59
68
  * the loop: instead of the FSM + `runTurn`, a wake drives a headless Claude
60
- * `query()` (see ZIG-784: "host is the product; the brain is how it decides").
69
+ * `query()` (see "host is the product; the brain is how it decides").
61
70
  */
62
71
  export declare class ClaudeSdkAgent {
63
72
  workflow: Workflow;
@@ -72,7 +81,7 @@ export declare class ClaudeSdkAgent {
72
81
  private statelessPerWake;
73
82
  private queryFn;
74
83
  /**
75
- * ZIG-1120 #7 — tool set is fixed at host construction; rebuilding the
84
+ * #7 — tool set is fixed at host construction; rebuilding the
76
85
  * in-process MCP server (+ zod schema tree) every wake was pure waste.
77
86
  * A mutable holder supplies per-wake context without closing over `tick`.
78
87
  */
@@ -9,7 +9,7 @@ import { claudeQueryOptions, buildClaudeEventPrompt, summarizeContext, extractAs
9
9
  import { assertHostedAgentZiggsTools } from './hostedAgentConnection.js';
10
10
  import { runtimeLog } from '../shared/runtimeLog.js';
11
11
  /**
12
- * ZIG-1035 / ZIG-1052: status notices must not spend a Claude turn answering
12
+ * status notices must not spend a Claude turn answering
13
13
  * nobody. Real work wakes arrive as chat `message`, synthetic `task_result`
14
14
  * from `wakeTaskAssignment`, or `agreement_lifecycle` (hire / proposal
15
15
  * resolution — the FSM kickoffs on the same event; Claude must act too).
@@ -35,30 +35,34 @@ export function isNoticeOnlyWake(event, ownAgentId) {
35
35
  }
36
36
  const DEFAULT_STATE = 'ready';
37
37
  /**
38
- * Per-task turn cap. A wake drives one `query()` that may loop up to this many
39
- * internal turns; the tool schema + system prompt ride every turn, so this is
40
- * the direct multiplier on a task's token spend. 30 let a confused MCP agent
41
- * burn schema×30 per wake; brokered tasks (fetch → record → done) converge in a
42
- * handful of turns, so 12 is ample. Env-overridable (raise for genuinely long
43
- * agents). ZIG-759. */
44
- const DEFAULT_MAX_TURNS = Number(process.env.CLAUDE_SDK_MAX_TURNS ?? 12);
38
+ * Per-wake turn cap — a runaway backstop, NOT a budget.
39
+ *
40
+ * Cost is bounded by the priced spend guard below, so this only has to sit above
41
+ * the longest honest job, with room for a retry inside it. A job that gathers
42
+ * before it works — reading the engagement, listing grants, discovering a
43
+ * connection's tools, pulling the data — spends around ten turns before it
44
+ * starts on the work it was hired for, then needs several more to draft, record
45
+ * the artifact and close the task. Set below that, the cap ends wakes with the
46
+ * gathering done and nothing filed.
47
+ */
48
+ const DEFAULT_MAX_TURNS = Number(process.env.CLAUDE_SDK_MAX_TURNS ?? 30);
45
49
  /**
46
50
  * Hard ceiling on billable input tokens (incl. cache read/write) a single wake
47
- * may spend before we abort the query. Defense-in-depth on the shared fleet key
51
+ * may spend before we abort the query. Defense-in-depth on the host's LLM key
48
52
  * against a runaway loop; set high enough that normal tasks never trip it. The
49
- * task ends unfinished (and logs loudly) rather than burning unbounded. ZIG-759. */
53
+ * task ends unfinished (and logs loudly) rather than burning unbounded. */
50
54
  const MAX_TASK_INPUT_TOKENS = Number(process.env.CLAUDE_SDK_MAX_INPUT_TOKENS ?? 500_000);
51
55
  /**
52
56
  * Per-lane wake-rate circuit breaker. The per-wake caps above bound one wake;
53
- * nothing bounded the NUMBER of wakes — the agent-private artifact echo drove
54
- * one wake per wake, forever. The echo is fixed at the source (backend
55
- * targeting never routes an agent its own writes), so this is a backstop for
56
- * any future echo path: a lane waking faster than real traffic plausibly drives it
57
- * skips the LLM (and logs loudly) instead of draining the fleet key. */
57
+ * nothing bounds the NUMBER of wakes. An echo path — anything that turns an
58
+ * agent's own write back into a delivery addressed to it — drives one wake per
59
+ * wake, forever. Targeting never routes an agent its own writes, so this is a
60
+ * backstop: a lane waking faster than real traffic plausibly drives it skips the
61
+ * LLM (and logs loudly) instead of draining the key. */
58
62
  const WAKE_WINDOW_MS = 10 * 60_000;
59
63
  const MAX_WAKES_PER_WINDOW = Number(process.env.CLAUDE_SDK_MAX_WAKES_PER_WINDOW ?? 40);
60
64
  /**
61
- * Resumed-transcript ceiling (ZIG-816). Each wake resumes the lane's Claude
65
+ * Resumed-transcript ceiling. Each wake resumes the lane's Claude
62
66
  * session, so the whole transcript (event prompts, tool results, summaries)
63
67
  * replays on every turn — mostly as cache reads, but it grows without bound on
64
68
  * a long-lived lane and dominates per-wake cost. When a wake's billable input
@@ -69,7 +73,7 @@ const MAX_WAKES_PER_WINDOW = Number(process.env.CLAUDE_SDK_MAX_WAKES_PER_WINDOW
69
73
  * before it can approach the per-wake abort. */
70
74
  const MAX_SESSION_RESUME_INPUT_TOKENS = Number(process.env.CLAUDE_SDK_MAX_SESSION_INPUT_TOKENS ?? 200_000);
71
75
  /**
72
- * ZIG-1191 — "the transcript you asked me to resume is not here".
76
+ * "the transcript you asked me to resume is not here".
73
77
  *
74
78
  * Claude Code answers a `resume` of an unknown id with this, and it is the only
75
79
  * failure that a fresh session reliably fixes: the id is valid-looking but its
@@ -77,6 +81,19 @@ const MAX_SESSION_RESUME_INPUT_TOKENS = Number(process.env.CLAUDE_SDK_MAX_SESSIO
77
81
  * that is all the CLI gives us — no code, no typed error — so keep it narrow
78
82
  * enough that a different failure never claims the retry.
79
83
  */
84
+ /**
85
+ * "I ran out of turns", as Claude Code reports it.
86
+ *
87
+ * Turn exhaustion is not a crash: the wake did real work and stopped at a cap
88
+ * we set. It arrives as a thrown error result with no code and no type, so the
89
+ * message is all there is to match on — kept narrow so no other failure claims
90
+ * the handling.
91
+ */
92
+ export function isMaxTurnsError(err) {
93
+ const message = err?.message;
94
+ return (typeof message === 'string' &&
95
+ /reached maximum number of turns/i.test(message));
96
+ }
80
97
  export function isMissingSessionError(err) {
81
98
  const message = err?.message;
82
99
  return (typeof message === 'string' &&
@@ -90,6 +107,32 @@ function billableInput(usage) {
90
107
  (usage.cache_read_input_tokens ?? 0) +
91
108
  (usage.cache_creation_input_tokens ?? 0));
92
109
  }
110
+ /**
111
+ * The same tokens, priced.
112
+ *
113
+ * A cache READ bills at ~0.1x base input and a cache WRITE at ~1.25x. The spend
114
+ * guard below exists to stop a runaway from draining the key, so it has to count
115
+ * what the key is actually charged — and counting a cache read as a full input
116
+ * token makes every multi-turn wake look like a runaway. The transcript replays
117
+ * on every turn, so a 40k context over ten turns reads as 400k of "spend" while
118
+ * costing about a tenth of that: unpriced, the guard fires on ordinary work long
119
+ * before any runaway would, and it fires mid-job — right after the gathering
120
+ * calls and before the deliverable, which is the whole of "the wake ends before
121
+ * it files anything".
122
+ *
123
+ * Weights, not prices: the ratio between the three input kinds is stable across
124
+ * Claude models even as the per-million rates change, so this stays right
125
+ * without tracking a price table.
126
+ */
127
+ const CACHE_READ_WEIGHT = 0.1;
128
+ const CACHE_WRITE_WEIGHT = 1.25;
129
+ function costWeightedInput(usage) {
130
+ if (!usage)
131
+ return 0;
132
+ return ((usage.input_tokens ?? 0) +
133
+ (usage.cache_read_input_tokens ?? 0) * CACHE_READ_WEIGHT +
134
+ (usage.cache_creation_input_tokens ?? 0) * CACHE_WRITE_WEIGHT);
135
+ }
93
136
  /**
94
137
  * Per-API-call usage. Only assistant turns carry it — the result message's
95
138
  * usage is the CUMULATIVE total of the whole query, so adding it on top of
@@ -106,12 +149,12 @@ function extractMetrics(message) {
106
149
  return null;
107
150
  const usage = message.usage;
108
151
  // Billable input includes cache reads/writes — that is what the Anthropic
109
- // console counts and what the fleet key pays for. Reporting only the
110
- // uncached slice made our telemetry read ~10-20x under the real usage.
152
+ // console counts and what the key is charged for. Reporting only the uncached
153
+ // slice understates real usage by more than an order of magnitude.
111
154
  const input = billableInput(usage);
112
155
  const output = usage?.output_tokens ?? 0;
113
156
  // Cache read/write are already folded into `input` above; surfaced separately
114
- // here for per-tick visibility (metricsTap metadata / dashboards, ZIG-813).
157
+ // here for per-tick visibility (metricsTap metadata / dashboards).
115
158
  const cacheRead = usage?.cache_read_input_tokens ?? 0;
116
159
  const cacheCreation = usage?.cache_creation_input_tokens ?? 0;
117
160
  return {
@@ -121,8 +164,8 @@ function extractMetrics(message) {
121
164
  };
122
165
  }
123
166
  /**
124
- * Resolve the agreement this wake acts under, for a delegated per-user MCP call
125
- * (ZIG-831/832). Without it the gateway resolves the connection owner from
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
126
169
  * `senderId` — which, under a delegation, is the delegating agent (who owns no
127
170
  * connection) → a spurious NO_USER_CONNECTION even though the payer IS connected.
128
171
  *
@@ -130,7 +173,7 @@ function extractMetrics(message) {
130
173
  * `task_result` events. In practice the worker often fetches while reacting to
131
174
  * the proposal-approved / agreement-active lifecycle events, which carry the id
132
175
  * at the event top level rather than under `result`. Reading only
133
- * `result.agreementId` (the original ZIG-831 wiring) left those wakes with no
176
+ * `result.agreementId` (the original wiring) left those wakes with no
134
177
  * agreement → the false "authorize your connection" prompt. Resolve from every
135
178
  * reliable source instead:
136
179
  * 1. task_result events → `result.agreementId`
@@ -170,7 +213,7 @@ function resolveOnBehalfAgreementId(rawEvent, contextSnap, agentId, sessionId) {
170
213
  * A Ziggs agent whose brain is the Claude Agent SDK. Same host, same `eff`
171
214
  * side channel, same tool surface as the FSM `Agent` — the ONLY difference is
172
215
  * the loop: instead of the FSM + `runTurn`, a wake drives a headless Claude
173
- * `query()` (see ZIG-784: "host is the product; the brain is how it decides").
216
+ * `query()` (see "host is the product; the brain is how it decides").
174
217
  */
175
218
  export class ClaudeSdkAgent {
176
219
  workflow;
@@ -185,7 +228,7 @@ export class ClaudeSdkAgent {
185
228
  statelessPerWake;
186
229
  queryFn;
187
230
  /**
188
- * ZIG-1120 #7 — tool set is fixed at host construction; rebuilding the
231
+ * #7 — tool set is fixed at host construction; rebuilding the
189
232
  * in-process MCP server (+ zod schema tree) every wake was pure waste.
190
233
  * A mutable holder supplies per-wake context without closing over `tick`.
191
234
  */
@@ -200,7 +243,7 @@ export class ClaudeSdkAgent {
200
243
  throw new Error('ClaudeSdkAgent: anthropicKey is required');
201
244
  this.workflow = opts.workflow;
202
245
  this.toolManager = opts.toolManager;
203
- // ZIG-1080: the prompt and the store blurb are two different strings. Agents
246
+ // the prompt and the store blurb are two different strings. Agents
204
247
  // written before the split still carry the prompt in `description`, so fall
205
248
  // back to it rather than silently dropping their role.
206
249
  this.rolePrompt = opts.systemPrompt || opts.description;
@@ -249,7 +292,7 @@ export class ClaudeSdkAgent {
249
292
  }
250
293
  let contextSnap = null;
251
294
  try {
252
- // ZIG-1120 #5: pre-read is for wake gating + summarizeContext (a count) +
295
+ // #5: pre-read is for wake gating + summarizeContext (a count) +
253
296
  // onBehalf agreement resolution — same 12-message cap as the tool path.
254
297
  // Uncapped pulls burned 35–47k input tokens/wake on long chats.
255
298
  contextSnap = await eff({
@@ -265,7 +308,7 @@ export class ClaudeSdkAgent {
265
308
  // no longer resolves (deleted / not visible) has no world to act in —
266
309
  // skip the query instead of burning a wake on a fabricated empty history.
267
310
  //
268
- // ZIG-975: a bare agreement id (`agr_…`, not the `agrn-…` notification
311
+ // a bare agreement id (`agr_…`, not the `agrn-…` notification
269
312
  // chat) is not a chat. If a wake still keys the lane that way, read-context
270
313
  // returns chatMissing and used to continue on an empty snapshot with no
271
314
  // signal. Loud-log that mis-key; message wakes still skip below.
@@ -297,7 +340,7 @@ export class ClaudeSdkAgent {
297
340
  this.specialization ? `Specialization: ${this.specialization}` : '',
298
341
  ].filter(Boolean).join('\n');
299
342
  // Two blocks, not one string: the shared protocol first so its cache prefix
300
- // is reachable by every hosted agent, this agent's role second (ZIG-788).
343
+ // is reachable by every hosted agent, this agent's role second.
301
344
  const systemPrompt = [CLAUDE_SDK_PROTOCOL, roleBlock];
302
345
  const runId = crypto.randomUUID();
303
346
  // Replay-stable write keys for this wake (runId is random and can't serve):
@@ -312,7 +355,7 @@ export class ClaudeSdkAgent {
312
355
  eff,
313
356
  senderId: rawEvent?.senderId != null ? String(rawEvent.senderId) : undefined,
314
357
  senderType: rawEvent?.senderType != null ? String(rawEvent.senderType) : undefined,
315
- // ZIG-831/832: agreement this wake acts under → lets a delegated per-user
358
+ ///832: agreement this wake acts under → lets a delegated per-user
316
359
  // MCP call resolve the originating end-user (payer) rather than the
317
360
  // delegating agent (senderId). Resolved from every reliable wire source,
318
361
  // not just task_result events — see resolveOnBehalfAgreementId.
@@ -351,8 +394,17 @@ export class ClaudeSdkAgent {
351
394
  });
352
395
  let metrics = { llmCalls: 0, tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } };
353
396
  let sessionId = nextCtx.claudeSessionId;
397
+ // Cost-weighted, cumulative: what this wake has charged the key so far.
354
398
  let spentInput = 0;
355
- // ZIG-860: stateless-per-wake never resumes — each wake is a fresh
399
+ // The transcript's SIZE, which is a different question from spend and needs
400
+ // a different measure: the largest single turn's input, unweighted. On the
401
+ // cumulative sum a lane with a modest transcript crosses the resume ceiling
402
+ // simply by taking enough turns, and forgets its session after every wake —
403
+ // so each wake re-gathers from zero and stalls at the same step.
404
+ let peakTurnInput = 0;
405
+ // Why the wake stopped, when it stopped on a guard rather than on its own.
406
+ let abortedBy = null;
407
+ // stateless-per-wake never resumes — each wake is a fresh
356
408
  // transcript, so per-wake input stays flat instead of replaying the whole
357
409
  // lane history every turn.
358
410
  const resumeId = this.statelessPerWake ? undefined : nextCtx.claudeSessionId;
@@ -379,7 +431,7 @@ export class ClaudeSdkAgent {
379
431
  sessionId = message.session_id;
380
432
  }
381
433
  // Surface the model's own prose so a hired Claude agent is as legible as
382
- // an FSM agent (ZIG-787). Each completed assistant turn's text is recorded
434
+ // an FSM agent. Each completed assistant turn's text is recorded
383
435
  // as a `thought` — the SAME agent-private channel the FSM uses for its
384
436
  // reasoning (runTurn `emitThought` → record-event → recordThought), so it
385
437
  // shows in the hired-agent view without cluttering the counterparty chat.
@@ -407,12 +459,23 @@ export class ClaudeSdkAgent {
407
459
  if (message.type === 'result' && message.subtype !== 'success') {
408
460
  runtimeLog.warn('ClaudeSdkAgent', `query ended without success subtype=${message.subtype} session=${identity.sessionId}`);
409
461
  }
410
- // Runaway guard (ZIG-759): abort before an unbounded loop drains the
411
- // shared fleet key. Breaking the for-await calls the generator's return(),
462
+ // Runaway guard: abort before an unbounded loop drains the
463
+ // key. Breaking the for-await calls the generator's return(),
412
464
  // which terminates the underlying query.
413
- spentInput += billableInput(usageOf(message));
465
+ const turnUsage = usageOf(message);
466
+ spentInput += costWeightedInput(turnUsage);
467
+ peakTurnInput = Math.max(peakTurnInput, billableInput(turnUsage));
468
+ // A wake that produced no per-turn usage still reports a cumulative
469
+ // total on the result message. Divided by the turns it covers, that is
470
+ // the average context per turn — a coarser read of the same transcript,
471
+ // used only when the precise one is unavailable.
472
+ if (message.type === 'result') {
473
+ const turns = Math.max(1, message.num_turns ?? 1);
474
+ peakTurnInput = Math.max(peakTurnInput, billableInput(message.usage) / turns);
475
+ }
414
476
  if (spentInput > MAX_TASK_INPUT_TOKENS) {
415
- runtimeLog.warn('ClaudeSdkAgent', `input-token budget exceeded (~${spentInput} > ${MAX_TASK_INPUT_TOKENS}) session=${identity.sessionId} agent=${this.agentId} — aborting query to cap spend (ZIG-759)`);
477
+ 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`);
416
479
  break;
417
480
  }
418
481
  }
@@ -421,11 +484,35 @@ export class ClaudeSdkAgent {
421
484
  signal?.removeEventListener('abort', onAbort);
422
485
  }
423
486
  };
487
+ /**
488
+ * Say, on the agent's own reasoning channel, that this wake stopped before
489
+ * it finished. Without the note a wake cut off by a guard is
490
+ * indistinguishable from one that delivered: the task stays `active` with
491
+ * `processing: false`, no artifact, no result, and nothing anywhere says
492
+ * why. The lane's history carries the reason instead, so a wedged-task
493
+ * detector has something to read.
494
+ */
495
+ const noteWakeCutShort = async (reason) => {
496
+ const text = `Wake stopped early: ${reason}. Work in progress was NOT filed — no ` +
497
+ `artifact was recorded and no task was closed by this wake.`;
498
+ await eff({
499
+ kind: 'record-event',
500
+ sessionId: identity.sessionId,
501
+ entry: { kind: 'thought', text },
502
+ idempotencyKey: writeKeys.artifactKey({ kind: 'thought', text }),
503
+ }).catch((err) => runtimeLog.warn('ClaudeSdkAgent', `record abort note failed session=${identity.sessionId}: ${err.message}`));
504
+ };
424
505
  try {
425
506
  await consumeQuery(resumeId);
426
507
  }
427
508
  catch (err) {
428
- // ZIG-1191: the resumed transcript is gone. Claude Code stores transcripts
509
+ // Turn exhaustion arrives as a thrown error result, not a break, so it
510
+ // needs its own note before the throw propagates.
511
+ if (isMaxTurnsError(err)) {
512
+ abortedBy = 'max-turns';
513
+ await noteWakeCutShort(`the turn cap (${this.maxTurns}) was reached`);
514
+ }
515
+ // the resumed transcript is gone. Claude Code stores transcripts
429
516
  // on the container's own disk while the session id outlives it — persisted
430
517
  // per lane in the session store with a 24h TTL — so a redeployed agents
431
518
  // task inherits an id pointing at a file that no longer exists. Every wake
@@ -442,9 +529,15 @@ export class ClaudeSdkAgent {
442
529
  throw err;
443
530
  }
444
531
  runtimeLog.info('ClaudeSdkAgent', `resumed Claude session ${resumeId} no longer exists (container replaced?) ` +
445
- `session=${identity.sessionId} agent=${this.agentId} — retrying once on a fresh session (ZIG-1191)`);
532
+ `session=${identity.sessionId} agent=${this.agentId} — retrying once on a fresh session`);
446
533
  nextCtx.claudeSessionId = undefined;
447
534
  sessionId = undefined;
535
+ // The first attempt failed before any turn ran, so it spent nothing and
536
+ // left no transcript. Carrying its counters into the retry would halve the
537
+ // budget the real attempt gets to work with.
538
+ spentInput = 0;
539
+ peakTurnInput = 0;
540
+ abortedBy = null;
448
541
  try {
449
542
  await consumeQuery(undefined);
450
543
  }
@@ -454,23 +547,31 @@ export class ClaudeSdkAgent {
454
547
  }
455
548
  }
456
549
  // Stateless-per-wake keeps no session id — clear any carried over from the
457
- // prior ctx so the next wake starts fresh too (ZIG-860). Otherwise remember
550
+ // prior ctx so the next wake starts fresh too. Otherwise remember
458
551
  // this wake's session for resume.
459
552
  if (this.statelessPerWake)
460
553
  nextCtx.claudeSessionId = undefined;
461
554
  else if (sessionId)
462
555
  nextCtx.claudeSessionId = sessionId;
463
- // ZIG-816: bound resumed-transcript growth. `metrics.tokens.input` is the
464
- // wake's billable input off the result message (dominated by the resumed
465
- // transcript's cache reads); `spentInput` covers an aborted wake that never
466
- // produced a result. Past the ceiling, forget the session so the next wake
467
- // starts fresh rather than paying an ever-larger replay.
468
- const wakeInput = Math.max(metrics.tokens.input, spentInput);
469
- if (nextCtx.claudeSessionId && wakeInput > MAX_SESSION_RESUME_INPUT_TOKENS) {
470
- runtimeLog.info('ClaudeSdkAgent', `resetting Claude session (wake input ~${wakeInput} > ${MAX_SESSION_RESUME_INPUT_TOKENS}) ` +
471
- `session=${identity.sessionId} agent=${this.agentId} — next wake starts fresh (ZIG-816)`);
556
+ // bound resumed-transcript growth. Past the ceiling, forget the session so
557
+ // the next wake starts fresh rather than paying an ever-larger replay.
558
+ //
559
+ // Measured as the largest SINGLE turn's input — that is the transcript,
560
+ // since every turn re-reads the whole thing. Summing the wake's turns
561
+ // instead measures how much WORK a wake did, not how much it carries: a lane
562
+ // whose transcript never leaves ~40k crosses the ceiling on turn five and
563
+ // drops its session on every wake. Resuming then never happens, so each wake
564
+ // re-reads the world from scratch and runs out of budget at the same step,
565
+ // and a standing job cannot advance.
566
+ if (nextCtx.claudeSessionId && peakTurnInput > MAX_SESSION_RESUME_INPUT_TOKENS) {
567
+ runtimeLog.info('ClaudeSdkAgent', `resetting Claude session (transcript ~${peakTurnInput} > ${MAX_SESSION_RESUME_INPUT_TOKENS}) ` +
568
+ `session=${identity.sessionId} agent=${this.agentId} — next wake starts fresh`);
472
569
  nextCtx.claudeSessionId = undefined;
473
570
  }
571
+ 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})`);
574
+ }
474
575
  return {
475
576
  ctx: nextCtx,
476
577
  state: state ?? DEFAULT_STATE,