@ziggs-ai/agent-sdk 0.10.0 → 0.10.2

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 (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +9 -1
  3. package/dist/AgentHost.d.ts +50 -14
  4. package/dist/AgentHost.js +129 -7
  5. package/dist/agent/Agent.js +2 -2
  6. package/dist/agent/ClaudeSdkAgent.d.ts +23 -1
  7. package/dist/agent/ClaudeSdkAgent.js +156 -84
  8. package/dist/agent/claudeCognition.d.ts +25 -3
  9. package/dist/agent/claudeCognition.js +33 -1
  10. package/dist/agent/claudeSdkTools.d.ts +2 -2
  11. package/dist/agent/claudeSdkTools.js +1 -1
  12. package/dist/agent/toolManagerToSdkMcp.js +1 -1
  13. package/dist/context/applyEffects.js +4 -3
  14. package/dist/index.d.ts +6 -4
  15. package/dist/index.js +7 -3
  16. package/dist/ingress/normalizeIncoming.d.ts +2 -2
  17. package/dist/ingress/normalizeIncoming.js +1 -1
  18. package/dist/runtime/AgentMachine.d.ts +4 -4
  19. package/dist/runtime/AgentMachine.js +6 -6
  20. package/dist/runtime/bootstrapCtx.js +6 -8
  21. package/dist/runtime/buildOutcome.d.ts +9 -1
  22. package/dist/runtime/buildOutcome.js +29 -6
  23. package/dist/runtime/{wakeWriteKeys.d.ts → deliveryWriteKeys.d.ts} +2 -2
  24. package/dist/runtime/{wakeWriteKeys.js → deliveryWriteKeys.js} +15 -22
  25. package/dist/runtime/runMechanical.d.ts +2 -2
  26. package/dist/runtime/runMechanical.js +2 -2
  27. package/dist/runtime/runTurn.d.ts +2 -2
  28. package/dist/runtime/runTurn.js +5 -5
  29. package/dist/runtime/taskIntake.js +6 -4
  30. package/dist/server/ConnectionPool.d.ts +11 -53
  31. package/dist/server/ConnectionPool.js +29 -60
  32. package/dist/server/EventQueue.d.ts +6 -0
  33. package/dist/server/EventQueue.js +21 -6
  34. package/dist/server/SeenMessages.d.ts +7 -0
  35. package/dist/server/SeenMessages.js +15 -0
  36. package/dist/server/ZiggsEffectHandler.d.ts +1 -0
  37. package/dist/server/ZiggsEffectHandler.js +18 -11
  38. package/dist/server/createHealthServer.js +1 -1
  39. package/dist/server/inboxLoop.d.ts +17 -2
  40. package/dist/server/inboxLoop.js +92 -16
  41. package/dist/server/polls/PollKind.d.ts +72 -0
  42. package/dist/server/polls/PollKind.js +53 -0
  43. package/dist/server/polls/PollRunner.d.ts +62 -0
  44. package/dist/server/polls/PollRunner.js +138 -0
  45. package/dist/server/questMatch.d.ts +5 -0
  46. package/dist/server/questMatch.js +14 -0
  47. package/dist/server/runLauncher.d.ts +0 -6
  48. package/dist/server/runLauncher.js +54 -27
  49. package/dist/server/tools/tier1/protocolRunner.d.ts +4 -2
  50. package/dist/server/tools/tier1/protocolRunner.js +30 -34
  51. package/dist/server/tools/tier1/protocolTools.js +23 -13
  52. package/dist/server/tools/tier2/connectionTools.js +12 -1
  53. package/dist/tasks/protocolRegistry.d.ts +27 -0
  54. package/dist/tasks/protocolRegistry.js +36 -0
  55. package/dist/types.d.ts +4 -13
  56. package/package.json +2 -2
  57. package/dist/server/staleBacklogGate.d.ts +0 -57
  58. package/dist/server/staleBacklogGate.js +0 -100
@@ -0,0 +1,138 @@
1
+ /**
2
+ * PollRunner — executes declared polls for a whole fleet, outside any host.
3
+ *
4
+ * Every host already long-polls its own Ziggs inbox; this applies the same wake
5
+ * discipline to the sources Ziggs cannot see — mailboxes, feeds, whatever a
6
+ * PollKind can ask. It runs in the launcher rather than inside any AgentHost
7
+ * because none of it belongs to a host's message loop: a poll is per
8
+ * (agent, binding) and carries its own interval, backoff and backpressure.
9
+ *
10
+ * One loop per (agent, binding), and each loop AWAITS its own wake before
11
+ * scheduling the next tick. That is the whole backpressure story: while an
12
+ * agent is still dealing with what it was woken for, its next poll has not
13
+ * happened yet, so it cannot be woken twice for the same thing. No in-flight
14
+ * bookkeeping, no dedup table — the source is authoritative and a poll that
15
+ * has not run yet cannot report anything stale.
16
+ *
17
+ * A failing source backs off exponentially and recovers on the first success.
18
+ * Without it a source that is down is hit at full rate indefinitely, which is
19
+ * how you get rate-limited by the thing you were trying to read.
20
+ */
21
+ import { runtimeLog } from '../../shared/runtimeLog.js';
22
+ import { declaredPolls } from './PollKind.js';
23
+ export class PollRunner {
24
+ pool;
25
+ operatorKey;
26
+ label;
27
+ maxBackoffMs;
28
+ wakeTimeoutMs;
29
+ loops = [];
30
+ running = false;
31
+ constructor({ pool, registry, agents, operatorKey, defaultIntervalMs = 60_000, maxBackoffMs = 15 * 60_000, wakeTimeoutMs = Number(process.env.CLAUDE_SDK_TICK_TIMEOUT_MS ?? 300_000), label = 'PollRunner', }) {
32
+ this.pool = pool;
33
+ this.operatorKey = operatorKey;
34
+ this.label = label;
35
+ this.maxBackoffMs = maxBackoffMs;
36
+ this.wakeTimeoutMs = wakeTimeoutMs;
37
+ for (const config of agents) {
38
+ const agentId = config['agentId'];
39
+ if (typeof agentId !== 'string' || !agentId)
40
+ continue;
41
+ for (const binding of declaredPolls(config)) {
42
+ const kind = registry.get(binding.kind);
43
+ if (!kind) {
44
+ // Loud, and does not stop the fleet: an agent declaring a kind nobody
45
+ // registered would otherwise sit there looking healthy and never wake.
46
+ runtimeLog.warn(label, `${agentId} declares unknown poll kind "${binding.kind}" — it will never be woken by it (registered: ${registry.ids.join(', ') || 'none'})`);
47
+ continue;
48
+ }
49
+ if (!binding.chatId) {
50
+ // Refused at startup rather than dropped per-wake: without a lane the
51
+ // host's session resolver returns null and the wake disappears with
52
+ // only a debug line, which looks exactly like "the source is quiet".
53
+ runtimeLog.warn(label, `${agentId} declares poll "${binding.kind}" without a chatId — refusing to register it, because every wake would be silently dropped`);
54
+ continue;
55
+ }
56
+ this.loops.push({
57
+ agentId,
58
+ binding,
59
+ kind,
60
+ intervalMs: kind.intervalMs ?? defaultIntervalMs,
61
+ chatId: binding.chatId,
62
+ timer: null,
63
+ failures: 0,
64
+ });
65
+ }
66
+ }
67
+ }
68
+ get size() {
69
+ return this.loops.length;
70
+ }
71
+ start() {
72
+ if (this.running)
73
+ return;
74
+ this.running = true;
75
+ if (!this.loops.length)
76
+ return;
77
+ for (const loop of this.loops)
78
+ this.schedule(loop, 0);
79
+ runtimeLog.info(this.label, `started — ${this.loops.length} poll(s): ${this.loops.map((l) => `${l.agentId}:${l.kind.id}`).join(', ')}`);
80
+ }
81
+ stop() {
82
+ this.running = false;
83
+ for (const loop of this.loops) {
84
+ if (loop.timer)
85
+ clearTimeout(loop.timer);
86
+ loop.timer = null;
87
+ }
88
+ runtimeLog.info(this.label, 'stopped');
89
+ }
90
+ schedule(loop, delayMs = this.nextDelay(loop)) {
91
+ if (!this.running)
92
+ return;
93
+ loop.timer = setTimeout(() => void this.tick(loop), delayMs);
94
+ }
95
+ /** Interval while healthy; doubling up to the ceiling while failing. */
96
+ nextDelay(loop) {
97
+ if (loop.failures === 0)
98
+ return loop.intervalMs;
99
+ return Math.min(loop.intervalMs * 2 ** loop.failures, this.maxBackoffMs);
100
+ }
101
+ async tick(loop) {
102
+ if (!this.running)
103
+ return;
104
+ let result;
105
+ try {
106
+ result = await loop.kind.poll(loop.binding, {
107
+ agentId: loop.agentId,
108
+ ...(this.operatorKey ? { operatorKey: this.operatorKey } : {}),
109
+ });
110
+ loop.failures = 0;
111
+ }
112
+ catch (err) {
113
+ loop.failures += 1;
114
+ runtimeLog.error(this.label, `${loop.agentId}:${loop.kind.id} source failed (${loop.failures}x, next in ${this.nextDelay(loop)}ms): ${err.message}`);
115
+ this.schedule(loop);
116
+ return;
117
+ }
118
+ if (result?.actionable) {
119
+ const hint = result.hint ?? `New work from ${loop.kind.id}. Check it.`;
120
+ try {
121
+ // Awaited on purpose — see the header. The next tick waits for the
122
+ // agent to finish, which is what stops a double wake. chatId is what
123
+ // makes the wake resolve to a lane at all, and wakeTimeoutMs has to
124
+ // outlast a real multi-turn wake or the await stops meaning anything.
125
+ await this.pool.sendTo(loop.agentId, hint, { chatId: loop.chatId, pollKind: loop.kind.id }, { timeoutMs: this.wakeTimeoutMs });
126
+ runtimeLog.info(this.label, `woke ${loop.agentId} for ${loop.kind.id}`);
127
+ }
128
+ catch (err) {
129
+ // Reported separately from a source failure: the source is fine, the
130
+ // agent is not. Still backs off, because hammering a wedged agent helps
131
+ // nobody.
132
+ loop.failures += 1;
133
+ runtimeLog.error(this.label, `${loop.agentId} wake failed (${loop.failures}x, next in ${this.nextDelay(loop)}ms): ${err.message}`);
134
+ }
135
+ }
136
+ this.schedule(loop);
137
+ }
138
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * ZIG-1185 — exact-match triage for marketplace quests.
3
+ * Empty match never hits (token-safe default). Comparison is exact after trim.
4
+ */
5
+ export declare function questExactMatch(match: string, tags: ReadonlyArray<string | null | undefined>): boolean;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * ZIG-1185 — exact-match triage for marketplace quests.
3
+ * Empty match never hits (token-safe default). Comparison is exact after trim.
4
+ */
5
+ export function questExactMatch(match, tags) {
6
+ const needle = typeof match === 'string' ? match.trim() : '';
7
+ if (!needle)
8
+ return false;
9
+ for (const t of tags) {
10
+ if (typeof t === 'string' && t.trim() === needle)
11
+ return true;
12
+ }
13
+ return false;
14
+ }
@@ -15,14 +15,8 @@ interface SingleOptions {
15
15
  onShutdown?: () => Promise<void>;
16
16
  }
17
17
  interface FleetOptions extends SingleOptions {
18
- wsUrl?: string;
19
18
  agentMeta?: Record<string, unknown>[];
20
19
  primary?: AgentConfig | ((pool: ConnectionPool) => AgentConfig);
21
- preStart?: string[];
22
- poolOptions?: {
23
- maxActive?: number;
24
- idleTimeoutMs?: number;
25
- };
26
20
  }
27
21
  export declare function runLauncher(configOrConfigs: AgentConfig | AgentConfig[], opts?: FleetOptions): Promise<unknown>;
28
22
  export {};
@@ -1,9 +1,39 @@
1
1
  import { ConnectionPool } from './ConnectionPool.js';
2
2
  import { AgentHost } from '../AgentHost.js';
3
3
  import { createHealthServer } from './createHealthServer.js';
4
- import { createStaleBacklogGate, resolveReplayPolicy } from './staleBacklogGate.js';
5
4
  import { boundKeyFleetWarning } from '../shared/operatorKeyClaims.js';
6
5
  import { runtimeLog } from '../shared/runtimeLog.js';
6
+ import { PollRegistry } from './polls/PollKind.js';
7
+ import { PollRunner } from './polls/PollRunner.js';
8
+ /**
9
+ * Collect the poll kinds the agents themselves ship and start a runner for
10
+ * whatever they declared. This lives in the launcher, not in a caller's
11
+ * bootstrap file: an agent that declares `polls` and gets no runner sees no
12
+ * error and simply never wakes, which is indistinguishable from a quiet source.
13
+ * Every launcher path therefore executes polls, or refuses loudly.
14
+ */
15
+ function startDeclaredPolls(configs, pool, { operatorKey, label }) {
16
+ const registry = new PollRegistry();
17
+ for (const cfg of configs) {
18
+ for (const kind of (cfg.pollKinds ?? [])) {
19
+ // Two agents may legitimately share a source; first registration wins and
20
+ // a duplicate id is not an error worth killing a fleet launch over.
21
+ if (!registry.get(kind.id))
22
+ registry.register(kind);
23
+ }
24
+ }
25
+ const runner = new PollRunner({
26
+ pool,
27
+ registry,
28
+ agents: configs,
29
+ label: `${label}-polls`,
30
+ ...(operatorKey ? { operatorKey } : {}),
31
+ });
32
+ if (runner.size === 0)
33
+ return null;
34
+ runner.start();
35
+ return runner;
36
+ }
7
37
  export async function runLauncher(configOrConfigs, opts = {}) {
8
38
  if (Array.isArray(configOrConfigs))
9
39
  return runFleet(configOrConfigs, opts);
@@ -21,8 +51,11 @@ async function runSingle(config, { operatorKey, label = 'agent', port = process.
21
51
  runtimeLog.warn(label, `Agent failed to connect at startup: ${err.message} — will retry`);
22
52
  }
23
53
  const server = healthServer ? createHealthServer({ port: Number(port), label }) : null;
54
+ // One agent, so the "pool" is just this host.
55
+ const pollRunner = startDeclaredPolls([decorated], { sendTo: (_agentId, text, metadata) => agent.handleMessage(text, metadata ?? {}) }, { label, ...(effectiveKey ? { operatorKey: effectiveKey } : {}) });
24
56
  process.on('SIGTERM', async () => {
25
57
  runtimeLog.info(label, 'SIGTERM — shutting down');
58
+ pollRunner?.stop();
26
59
  agent.disconnect();
27
60
  await agent.drain(30_000);
28
61
  try {
@@ -36,14 +69,8 @@ async function runSingle(config, { operatorKey, label = 'agent', port = process.
36
69
  });
37
70
  return { agent, healthServer: server ?? undefined };
38
71
  }
39
- async function runFleet(configs, { operatorKey, wsUrl = process.env.WS_URL, port = process.env.PORT || 8080, healthServer = true, label = 'launcher', agentMeta, primary: primaryConfig, preStart = [], poolOptions, onShutdown, } = {}) {
40
- // ZIG-910: don't resurrect stale backlogs at bootstrap. Env-driven default
41
- // (FLEET_REPLAY_MAX_AGE / FLEET_REPLAY_STALE); an explicit
42
- // poolOptions.shouldLazyStart wins.
43
- const pool = new ConnectionPool({
44
- shouldLazyStart: createStaleBacklogGate({ ...resolveReplayPolicy(), label }),
45
- ...poolOptions,
46
- });
72
+ async function runFleet(configs, { operatorKey, port = process.env.PORT || 8080, healthServer = true, label = 'launcher', agentMeta, primary: primaryConfig, onShutdown, } = {}) {
73
+ const pool = new ConnectionPool();
47
74
  const effectiveKey = operatorKey ?? process.env.ZIGGS_OPERATOR_KEY;
48
75
  const decoratedConfigs = effectiveKey
49
76
  ? configs.map(c => (c.operatorKey ? c : { ...c, operatorKey: effectiveKey }))
@@ -63,10 +90,6 @@ async function runFleet(configs, { operatorKey, wsUrl = process.env.WS_URL, port
63
90
  const boundWarning = boundKeyFleetWarning(controlKey, pool.size);
64
91
  if (boundWarning)
65
92
  runtimeLog.warn(label, boundWarning);
66
- pool.startControl({ wsUrl, operatorKey: controlKey });
67
- }
68
- else {
69
- runtimeLog.warn(label, 'No operatorKey provided — skipping fleet inbox poll (no agents will be lazily started)');
70
93
  }
71
94
  let primary = null;
72
95
  if (resolvedPrimaryConfig) {
@@ -76,26 +99,30 @@ async function runFleet(configs, { operatorKey, wsUrl = process.env.WS_URL, port
76
99
  runtimeLog.info(label, `Primary agent "${resolvedPrimaryConfig.name || resolvedPrimaryConfig.ownAgentId || 'primary'}" connected`);
77
100
  }
78
101
  catch (err) {
79
- // Backend may not be ready yet — socket.io reconnects automatically.
80
- // Don't crash the process; the primary agent will be live once it reconnects.
102
+ // Backend may not be ready yet — the inbox long-poll loop retries on
103
+ // the next pass. Don't crash the process.
81
104
  runtimeLog.warn(label, `Primary agent "${resolvedPrimaryConfig.name || resolvedPrimaryConfig.ownAgentId || 'primary'}" failed to connect at startup: ${err.message} — will retry`);
82
105
  }
83
106
  }
84
- if (preStart.length > 0) {
85
- // Pinned: these agents are up because the operator named them
86
- // (WAKE_AGENTS), not because work arrived, so the idle sweep must not
87
- // retire them. It used to: an empty long-poll isn't "activity", so a quiet
88
- // pre-started agent was torn down after idleTimeoutMs and only lazy start
89
- // could bring it back — which at fleet size it cannot (ZIG-965 tail).
90
- await Promise.all(preStart.map(id => pool.startAgent(id, { pinned: true })
91
- .then(() => runtimeLog.debug(label, `Pre-started "${id}" (pinned)`))
92
- .catch(err => runtimeLog.warn(label, `Could not pre-start "${id}": ${err.message}`))));
107
+ // Every registered agent connects (ZIG-1108). There is no roster to consult
108
+ // and no reason to defer: an agent this launcher was handed is an agent it
109
+ // hosts, and each host's own inbox long-poll is the only wake signal needed.
110
+ await pool.connectAll();
111
+ const connected = pool.listActive().length;
112
+ if (connected < pool.size) {
113
+ // Say it loudly and with the number. A launcher that silently hosts fewer
114
+ // agents than it was given is exactly how three published, hireable agents
115
+ // ran with no host and nothing noticed (ZIG-1107).
116
+ runtimeLog.warn(label, `Only ${connected}/${pool.size} agent(s) connected — the rest are registered but unhosted`);
93
117
  }
94
- runtimeLog.info(label, `Ready — ${pool.size} agent(s) registered, ${pool.listActive().length} connected` +
95
- (pool.listPinned().length ? ` (${pool.listPinned().length} pinned)` : ''));
118
+ runtimeLog.info(label, `Ready — ${connected}/${pool.size} agent(s) connected`);
119
+ const pollRunner = startDeclaredPolls(decoratedConfigs, pool, {
120
+ label,
121
+ ...(effectiveKey ? { operatorKey: effectiveKey } : {}),
122
+ });
96
123
  process.on('SIGTERM', async () => {
97
124
  runtimeLog.info(label, 'SIGTERM — shutting down');
98
- pool.stopControl();
125
+ pollRunner?.stop();
99
126
  primary?.disconnect?.();
100
127
  await primary?.drain?.(30_000);
101
128
  await pool.disconnectAll();
@@ -1,6 +1,6 @@
1
1
  import type { AgreementService } from '../../agreements/AgreementService.js';
2
2
  import type { TaskService } from '../../tasks/TaskService.js';
3
- import type { WakeWriteKeys } from '../../../types.js';
3
+ import type { DeliveryWriteKeys } from '../../../types.js';
4
4
  import { type ContextReadClient } from '@ziggs-ai/api-client';
5
5
  interface Services {
6
6
  agreementService: AgreementService;
@@ -36,5 +36,7 @@ interface ProtocolPayload {
36
36
  })[];
37
37
  [key: string]: unknown;
38
38
  }
39
- export declare function dispatchProtocolOp(services: Services, payload: ProtocolPayload, chatId: string | null, agents?: unknown[], actingAgentId?: string | null, wakeKeys?: WakeWriteKeys | null): Promise<unknown>;
39
+ export declare function dispatchProtocolOp(services: Services, payload: ProtocolPayload, chatId: string | null, agents?: unknown[], actingAgentId?: string | null, writeKeys?: DeliveryWriteKeys | null,
40
+ /** ZIG-1120 #8 — prefer the cached session snapshot over a fresh HTTP read. */
41
+ cachedAgreements?: unknown[]): Promise<unknown>;
40
42
  export {};
@@ -1,17 +1,9 @@
1
- import { isBroadcastTarget, linkSummary, } from '@ziggs-ai/api-client';
1
+ import { isBroadcastTarget, } from '@ziggs-ai/api-client';
2
2
  import { applyFleetCounterPricing, applyFleetPricingForProtocol, } from '../../../pricing/fleetDefaults.js';
3
- /**
4
- * ZIG-957 — same summary shape as MCP link mutations / link_* tools.
5
- * Raw Mongo money/approvals noise must not reach the LLM on a trust link.
6
- */
7
- function shapeAgreementResult(agreement) {
8
- if (!agreement)
9
- return agreement;
10
- return agreement.engagementKind === 'link' ? linkSummary(agreement) : agreement;
11
- }
12
- function shapeRevokeResult(result) {
13
- return { ...result, agreement: shapeAgreementResult(result.agreement) };
14
- }
3
+ // ZIG-1111: the link-summary rule lives in AgreementClient, at the point the
4
+ // document is parsed. Every verb here inherits it through the client instead of
5
+ // each one restating `engagementKind === 'link' ? linkSummary(a) : a` — which is
6
+ // how counter, fulfill and subcontract came to return the raw document.
15
7
  // Map legacy task-state aliases onto the canonical set.
16
8
  function normalizeState(state) {
17
9
  if (state === 'done')
@@ -53,7 +45,9 @@ function normalizePlanInPlace(payload) {
53
45
  ? 'before_execution'
54
46
  : 'with_proposal';
55
47
  }
56
- export async function dispatchProtocolOp(services, payload, chatId, agents = [], actingAgentId = null, wakeKeys = null) {
48
+ export async function dispatchProtocolOp(services, payload, chatId, agents = [], actingAgentId = null, writeKeys = null,
49
+ /** ZIG-1120 #8 — prefer the cached session snapshot over a fresh HTTP read. */
50
+ cachedAgreements) {
57
51
  if (!payload?.operation)
58
52
  return null;
59
53
  const { operation } = payload;
@@ -61,22 +55,18 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
61
55
  const actorId = actingAgentId?.trim() || null;
62
56
  switch (operation) {
63
57
  case 'agreement-propose':
64
- return proposeAgreement(services, payload, chatId, agents, actorId, wakeKeys);
58
+ return proposeAgreement(services, payload, chatId, agents, actorId, writeKeys);
65
59
  case 'agreement-subcontract':
66
- return subcontractAgreement(services, payload, chatId, agents, actorId, wakeKeys);
60
+ return subcontractAgreement(services, payload, chatId, agents, actorId, writeKeys, cachedAgreements);
67
61
  case 'agreement-respond':
68
62
  if (!payload.agreementId || !payload.action)
69
63
  return null;
70
- return shapeAgreementResult(await agreementService.respond(payload.agreementId, payload.action));
64
+ return agreementService.respond(payload.agreementId, payload.action);
71
65
  case 'agreement-claim': {
72
66
  // ZIG-1021: the one claim verb — quest, standing offer, or link invite.
73
67
  if (!payload.agreementId)
74
68
  throw new Error('agreement-claim requires `agreementId`');
75
- const claimed = await agreementService.claim(payload.agreementId);
76
- return {
77
- ...claimed,
78
- agreement: shapeAgreementResult(claimed.agreement),
79
- };
69
+ return await agreementService.claim(payload.agreementId);
80
70
  }
81
71
  case 'agreement-counter-proposal':
82
72
  if (!payload.agreementId)
@@ -101,16 +91,16 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
101
91
  case 'agreement-revoke':
102
92
  if (!payload.agreementId)
103
93
  return null;
104
- return shapeRevokeResult(await agreementService.revoke(payload.agreementId));
94
+ return await agreementService.revoke(payload.agreementId);
105
95
  case 'agreement-fulfill':
106
96
  if (!payload.agreementId)
107
97
  return null;
108
- return shapeRevokeResult(await agreementService.fulfill(payload.agreementId));
98
+ return await agreementService.fulfill(payload.agreementId);
109
99
  case 'task-spawn': {
110
100
  if (!payload.agreementId || !payload.description)
111
101
  return null;
112
102
  let spawnAgreementId = payload.agreementId;
113
- const ags = await agreementsInChat(services, chatId);
103
+ const ags = await agreementsInChat(services, chatId, cachedAgreements);
114
104
  // b2 (ZIG-832): if the assignee is not a party on the cited agreement,
115
105
  // redirect the spawn to the ACTIVE sub-agreement (same root) where the
116
106
  // assignee IS the provider — the orchestrator often cites the top-level
@@ -177,7 +167,7 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
177
167
  // creating a duplicate. Keyed on what is actually written (the resolved
178
168
  // agreement, not the LLM-cited one), so multiple spawns in one wake stay
179
169
  // distinct.
180
- idempotencyKey: wakeKeys?.taskSpawnKey({
170
+ idempotencyKey: writeKeys?.taskSpawnKey({
181
171
  agreementId: spawnAgreementId,
182
172
  description: payload.description,
183
173
  assigneeId: payload.assigneeId,
@@ -194,7 +184,7 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
194
184
  const state = normalizeState(terminalRaw);
195
185
  // Wake-derived idempotency key: a crash-replay re-issuing this transition
196
186
  // no-ops server-side instead of erroring on the already-terminal task.
197
- return taskService.updateState(payload.taskId, state, payload.result, wakeKeys?.taskStateKey(payload.taskId, state));
187
+ return taskService.updateState(payload.taskId, state, payload.result, writeKeys?.taskStateKey(payload.taskId, state));
198
188
  }
199
189
  case 'task-update-plan-step':
200
190
  if (!payload.taskId)
@@ -217,7 +207,7 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
217
207
  throw new Error(`Unknown protocol operation: ${operation}`);
218
208
  }
219
209
  }
220
- async function proposeAgreement(services, payload, chatId, _agents, actingAgentId, wakeKeys = null) {
210
+ async function proposeAgreement(services, payload, chatId, _agents, actingAgentId, writeKeys = null) {
221
211
  if (!payload.description)
222
212
  throw new Error('agreement-propose requires `description`');
223
213
  if (!payload.proposedTo)
@@ -229,7 +219,7 @@ async function proposeAgreement(services, payload, chatId, _agents, actingAgentI
229
219
  if (isBroadcast) {
230
220
  throw new Error('A broadcast link is an invite — use link_create_invite instead; propose links to one agent id.');
231
221
  }
232
- return shapeAgreementResult(await services.agreementService.requestLink(payload.proposedTo, payload.description));
222
+ return services.agreementService.requestLink(payload.proposedTo, payload.description);
233
223
  }
234
224
  if (!isBroadcast && !payload.providerId) {
235
225
  throw new Error('agreement-propose requires `providerId` — name who does the work: your own id (you are offering to work) or the proposedTo id (you are commissioning the recipient).');
@@ -258,7 +248,7 @@ async function proposeAgreement(services, payload, chatId, _agents, actingAgentI
258
248
  // propose resolves to the already-created agreement server-side instead of
259
249
  // minting a duplicate proposal. Keyed on what the proposal writes, so
260
250
  // multiple proposals in one wake stay distinct.
261
- const idempotencyKey = wakeKeys?.agreementProposeKey({
251
+ const idempotencyKey = writeKeys?.agreementProposeKey({
262
252
  proposedTo: payload.proposedTo,
263
253
  providerId: payload.providerId,
264
254
  description: payload.description,
@@ -274,9 +264,15 @@ async function proposeAgreement(services, payload, chatId, _agents, actingAgentI
274
264
  // rejects with 400 "assignee is not a party". Both are made deterministic here
275
265
  // using the chat's existing agreements.
276
266
  const TERMINAL_STATUS = new Set(['fulfilled', 'cancelled', 'refunded', 'rejected']);
277
- async function agreementsInChat(services, chatId) {
267
+ async function agreementsInChat(services, chatId, cachedAgreements) {
278
268
  if (!chatId)
279
269
  return [];
270
+ // ZIG-1120 #8 — createZiggsEffectHandler already cached the session snapshot;
271
+ // prefer that over another GET /context/snapshot just for the agreements array.
272
+ // Empty array is a real answer ("none in this chat") — do not re-fetch.
273
+ if (cachedAgreements !== undefined) {
274
+ return Array.isArray(cachedAgreements) ? cachedAgreements : [];
275
+ }
280
276
  try {
281
277
  const snap = await services.contextReadClient.snapshot(chatId, { maxMessages: 1 });
282
278
  const ags = snap.agreements;
@@ -305,7 +301,7 @@ function agActive(a) {
305
301
  function agIsActiveStatus(a) {
306
302
  return String(a.status ?? '') === 'active';
307
303
  }
308
- async function subcontractAgreement(services, payload, chatId, _agents, actingAgentId, wakeKeys = null) {
304
+ async function subcontractAgreement(services, payload, chatId, _agents, actingAgentId, writeKeys = null, cachedAgreements) {
309
305
  if (!payload.description || !payload.executorId)
310
306
  return null;
311
307
  // b1 (ZIG-832): idempotent subcontract — if a non-terminal sub-agreement for
@@ -313,7 +309,7 @@ async function subcontractAgreement(services, payload, chatId, _agents, actingAg
313
309
  // creating a duplicate (which bloats the chain and blocks the fulfill cascade).
314
310
  if (chatId) {
315
311
  const parent = payload.parentAgreementId;
316
- const existing = (await agreementsInChat(services, chatId)).find((a) => agActive(a) && agIsProvider(a, payload.executorId) &&
312
+ const existing = (await agreementsInChat(services, chatId, cachedAgreements)).find((a) => agActive(a) && agIsProvider(a, payload.executorId) &&
317
313
  (!parent || a.parentAgreementId === parent || a.rootAgreementId === parent));
318
314
  if (existing?.agreementId)
319
315
  return existing;
@@ -365,7 +361,7 @@ async function subcontractAgreement(services, payload, chatId, _agents, actingAg
365
361
  // the b1 reuse check above (e.g. no chatId, or the sub not yet visible in the
366
362
  // snapshot) resolves to the already-created sub-agreement server-side instead
367
363
  // of minting a duplicate.
368
- const idempotencyKey = wakeKeys?.agreementSubcontractKey({
364
+ const idempotencyKey = writeKeys?.agreementSubcontractKey({
369
365
  executorId: payload.executorId,
370
366
  description: payload.description,
371
367
  parentAgreementId: payload.parentAgreementId ?? '',
@@ -1,4 +1,5 @@
1
1
  import { defineTool } from '../../../tools/defineTool.js';
2
+ import { requireProtocolOperation } from '../../../tasks/protocolRegistry.js';
2
3
  import { dispatchProtocolOp } from './protocolRunner.js';
3
4
  /** ZIG-957 — keep these enums in lockstep with MCP ziggs_agreement_* / ziggs_task_create. */
4
5
  const LIFECYCLE_ENUM = ['open', 'time-bound', 'count-bound'];
@@ -13,7 +14,16 @@ const planReviewTimingParam = {
13
14
  enum: [...PLAN_REVIEW_TIMING_ENUM],
14
15
  description: 'When the buyer reviews the plan: with_proposal | before_execution',
15
16
  };
16
- function handlerFor(operation) {
17
+ /**
18
+ * The handler for a protocol verb, looked up by tool name in the registry
19
+ * (ZIG-1110). Keying off the tool name rather than restating the operation is
20
+ * what makes half-registration impossible: a verb missing from
21
+ * PROTOCOL_TOOL_TO_OPERATION throws here, while this module loads, instead of
22
+ * dispatching correctly and then being unrecognised as protocol at every
23
+ * consumer that decides whether its result reaches context.
24
+ */
25
+ function handlerFor(toolName) {
26
+ const operation = requireProtocolOperation(toolName);
17
27
  return async (args, ctx) => {
18
28
  const c = ctx;
19
29
  if (!c.taskService)
@@ -26,16 +36,16 @@ function handlerFor(operation) {
26
36
  taskService: c.taskService,
27
37
  agreementService: c.agreementService,
28
38
  contextReadClient: c.contextReadClient,
29
- }, { operation, ...args }, c.chatId ?? null, c.agents ?? [], typeof c.agentId === 'string' ? c.agentId : null, c.wakeKeys ?? null);
39
+ }, { operation, ...args }, c.chatId ?? null, c.agents ?? [], typeof c.agentId === 'string' ? c.agentId : null, c.writeKeys ?? null, Array.isArray(c.agreements) ? c.agreements : undefined);
30
40
  if (result == null)
31
41
  throw new Error(`Operation ${operation} returned no result`);
32
42
  return result;
33
43
  };
34
44
  }
35
- export const agreementProposeTool = defineTool('agreement_propose', { description: { type: 'string', required: true }, proposedTo: { type: 'string', required: true }, engagementKind: { type: 'string', enum: ['hire', 'service', 'link'], description: "Contract type. Defaults to service. 'link' proposes a bilateral trust link (no work, no money)." }, parentAgreementId: 'string', providerId: { type: 'string', description: 'Who does the work — required on direct proposals. Pass your own id when YOU are offering to do the work, or the proposedTo id when you are commissioning the RECIPIENT (any other third party needs a matching published offer). On a broadcast: omit for a quest (the claimer works, you pay), or pass your own id to publish a standing offer (you work, the claimer pays). The payer is always derived as the non-providing side.' }, price: { type: 'number', description: 'Amount in CENTS — 500 means $5.00, shown as ϟ5.00 in the UI. Convert both ways: a human asking for "ϟ2" or "$2 per task" means 200, not 2. ϟ is the currency symbol, not a cent.' }, lifecycle: lifecycleParam, expiresAt: 'string', maxExecutions: 'integer', billing: { type: 'string', enum: ['total', 'per_task'], description: "How price reads: 'total' (default, escrowed and paid at the end) or 'per_task' (a rate settled as each task completes — standing agreements only, and the default for a hire)." }, agreementDescription: 'string' }, handlerFor('agreement-propose'), { description: 'Propose an agreement — direct, broadcast, or link; there are no separate publish tools. Direction is stated, never guessed: providerId = your own id ("I work, you pay") or providerId = proposedTo ("you work, I pay") on direct proposals. Broadcast with proposedTo="everyone" (fully public) or "org" (your org only): no providerId publishes a QUEST any agent may claim; providerId = your own id publishes a STANDING OFFER (claiming is agreement_claim; browsing is marketplace_view). engagementKind "link" with proposedTo = an agent id proposes bilateral trust. engagementKind defaults to service. Agreements are STANDING by default (no expiry, unlimited tasks under one agreement); set expiresAt or maxExecutions only when the engagement should end on its own.', isAgreementCreation: true });
36
- export const agreementSubcontractTool = defineTool('agreement_subcontract', { description: { type: 'string', required: true }, executorId: { type: 'string', required: true }, parentAgreementId: { type: 'string', required: true }, payerId: 'string', price: { type: 'number', description: 'Amount in CENTS — 500 means $5.00, shown as ϟ5.00 in the UI. Convert both ways: a human asking for "ϟ2" or "$2 per task" means 200, not 2. ϟ is the currency symbol, not a cent.' }, lifecycle: lifecycleParam, expiresAt: 'string', maxExecutions: 'integer', agreementDescription: 'string' }, handlerFor('agreement-subcontract'), { description: 'Delegate work to another agent under an existing parent agreement. Requires parentAgreementId.', isAgreementCreation: true });
37
- export const agreementRespondTool = defineTool('agreement_respond', { agreementId: { type: 'string', required: true }, action: { type: 'string', required: true, enum: ['approve', 'reject'] } }, handlerFor('agreement-respond'), { description: 'Approve or reject a pending DIRECT agreement proposal addressed to you. Open broadcasts (quests, standing offers, link invites) have no personal approval slot — claim those with agreement_claim instead, or ignore them to pass. ONE exception: a hand-off that pins YOU as provider carries your pending approval even as an open broadcast — approving consents to serving whoever claims it (the row stays open for claims); rejecting cancels the hand-off. Ignoring it leaves the hand-off unclaimable, so always respond.' });
38
- export const agreementClaimTool = defineTool('agreement_claim', { agreementId: { type: 'string', required: true, description: 'The open agreement to claim (quest / offer / link invite id)' } }, handlerFor('agreement-claim'), { description: 'Claim an open broadcast agreement by id — a quest (you do the work, the publisher pays), a standing offer (you buy, the publisher works), a hand-off (providerPinned: the publisher\'s hired agent works FOR you — you become the customer, and the payer when it is priced), or a link invite (bilateral trust forms immediately). You become the open party and the agreement activates. A hand-off is claimable only after its provider has accepted (409 until then). Find quests/offers with marketplace_view; direct proposals are approved with agreement_respond instead, not claimed.' });
45
+ export const agreementProposeTool = defineTool('agreement_propose', { description: { type: 'string', required: true }, proposedTo: { type: 'string', required: true }, engagementKind: { type: 'string', enum: ['hire', 'service', 'link'], description: "Contract type. Defaults to service. 'link' proposes a bilateral trust link (no work, no money)." }, parentAgreementId: 'string', providerId: { type: 'string', description: 'Who does the work — required on direct proposals. Pass your own id when YOU are offering to do the work, or the proposedTo id when you are commissioning the RECIPIENT (any other third party needs a matching published offer). On a broadcast: omit for a quest (the claimer works, you pay), or pass your own id to publish a standing offer (you work, the claimer pays). The payer is always derived as the non-providing side.' }, price: { type: 'number', description: 'Amount in CENTS — 500 means $5.00, shown as ϟ5.00 in the UI. Convert both ways: a human asking for "ϟ2" or "$2 per task" means 200, not 2. ϟ is the currency symbol, not a cent.' }, lifecycle: lifecycleParam, expiresAt: 'string', maxExecutions: 'integer', billing: { type: 'string', enum: ['total', 'per_task'], description: "How price reads: 'total' (default, escrowed and paid at the end) or 'per_task' (a rate settled as each task completes — standing agreements only, and the default for a hire)." }, agreementDescription: 'string' }, handlerFor('agreement_propose'), { description: 'Propose an agreement — direct, broadcast, or link; there are no separate publish tools. Direction is stated, never guessed: providerId = your own id ("I work, you pay") or providerId = proposedTo ("you work, I pay") on direct proposals. Broadcast with proposedTo="everyone" (fully public) or "org" (your org only): no providerId publishes a QUEST any agent may claim; providerId = your own id publishes a STANDING OFFER (claiming is agreement_claim; browsing is marketplace_view). engagementKind "link" with proposedTo = an agent id proposes bilateral trust. engagementKind defaults to service. Agreements are STANDING by default (no expiry, unlimited tasks under one agreement); set expiresAt or maxExecutions only when the engagement should end on its own.', isAgreementCreation: true });
46
+ export const agreementSubcontractTool = defineTool('agreement_subcontract', { description: { type: 'string', required: true }, executorId: { type: 'string', required: true }, parentAgreementId: { type: 'string', required: true }, payerId: 'string', price: { type: 'number', description: 'Amount in CENTS — 500 means $5.00, shown as ϟ5.00 in the UI. Convert both ways: a human asking for "ϟ2" or "$2 per task" means 200, not 2. ϟ is the currency symbol, not a cent.' }, lifecycle: lifecycleParam, expiresAt: 'string', maxExecutions: 'integer', agreementDescription: 'string' }, handlerFor('agreement_subcontract'), { description: 'Delegate work to another agent under an existing parent agreement. Requires parentAgreementId.', isAgreementCreation: true });
47
+ export const agreementRespondTool = defineTool('agreement_respond', { agreementId: { type: 'string', required: true }, action: { type: 'string', required: true, enum: ['approve', 'reject'] } }, handlerFor('agreement_respond'), { description: 'Approve or reject a pending DIRECT agreement proposal addressed to you. Open broadcasts (quests, standing offers, link invites) have no personal approval slot — claim those with agreement_claim instead, or ignore them to pass. ONE exception: a hand-off that pins YOU as provider carries your pending approval even as an open broadcast — approving consents to serving whoever claims it (the row stays open for claims); rejecting cancels the hand-off. Ignoring it leaves the hand-off unclaimable, so always respond.' });
48
+ export const agreementClaimTool = defineTool('agreement_claim', { agreementId: { type: 'string', required: true, description: 'The open agreement to claim (quest / offer / link invite id)' } }, handlerFor('agreement_claim'), { description: 'Claim an open broadcast agreement by id — a quest (you do the work, the publisher pays), a standing offer (you buy, the publisher works), a hand-off (providerPinned: the publisher\'s hired agent works FOR you — you become the customer, and the payer when it is priced), or a link invite (bilateral trust forms immediately). You become the open party and the agreement activates. A hand-off is claimable only after its provider has accepted (409 until then). Find quests/offers with marketplace_view; direct proposals are approved with agreement_respond instead, not claimed.' });
39
49
  export const taskSetResultTool = defineTool('task_set_result', {
40
50
  taskId: { type: 'string', required: true },
41
51
  // ZIG-957: same param name as MCP ziggs_task_set_result / PATCH /tasks/:id/state.
@@ -45,11 +55,11 @@ export const taskSetResultTool = defineTool('task_set_result', {
45
55
  type: 'object',
46
56
  description: 'Outcome payload (summary, status, links, …). Finished work is the task result — never a chat message.',
47
57
  },
48
- }, handlerFor('task-update'), { description: 'Transition a task to its terminal state — completed, failed, or cancelled — and record the result. Finished work is the task result: set it here as an object ({ summary, status, links }), never as a chat message. Pass state (not status).' });
49
- export const agreementCounterTool = defineTool('agreement_counter', { agreementId: { type: 'string', required: true }, price: { type: 'number', description: 'Amount in CENTS — 500 means $5.00, shown as ϟ5.00 in the UI. Convert both ways: a human asking for "ϟ2" or "$2 per task" means 200, not 2. ϟ is the currency symbol, not a cent.' }, agreementDescription: 'string', expiresAt: 'string', lifecycle: lifecycleParam, maxExecutions: 'integer', description: 'string' }, handlerFor('agreement-counter-proposal'), { description: 'Counter a pending proposal with revised terms instead of approving or rejecting. Check status first with agreement_get.' });
50
- export const agreementGetTool = defineTool('agreement_get', { agreementId: { type: 'string', required: true } }, handlerFor('agreement-check-proposal'), { description: 'Read the current proposal status for an agreement (pending, approved, rejected, countered, expired).' });
51
- export const agreementRevokeTool = defineTool('agreement_revoke', { agreementId: { type: 'string', required: true } }, handlerFor('agreement-revoke'), { description: 'Revoke a pending proposal you created so it can no longer be approved. Use when the user has clearly moved on to a different request before responding to the previous proposal card.' });
52
- export const agreementFulfillTool = defineTool('agreement_fulfill', { agreementId: { type: 'string', required: true } }, handlerFor('agreement-fulfill'), { description: 'END an agreement you provide — permanently. Fulfilling terminates the whole relationship, not one deliverable: every grant it conferred is revoked, its shared space is torn down, and it cannot be reopened. Finished WORK is reported via task_set_result (the task closes; the agreement stays standing for the next task). Only fulfill when the engagement\'s full scope is delivered and nothing more is expected — never a standing hire that just finished a task.' });
58
+ }, handlerFor('task_set_result'), { description: 'Transition a task to its terminal state — completed, failed, or cancelled — and record the result. Finished work is the task result: set it here as an object ({ summary, status, links }), never as a chat message. Pass state (not status).' });
59
+ export const agreementCounterTool = defineTool('agreement_counter', { agreementId: { type: 'string', required: true }, price: { type: 'number', description: 'Amount in CENTS — 500 means $5.00, shown as ϟ5.00 in the UI. Convert both ways: a human asking for "ϟ2" or "$2 per task" means 200, not 2. ϟ is the currency symbol, not a cent.' }, agreementDescription: 'string', expiresAt: 'string', lifecycle: lifecycleParam, maxExecutions: 'integer', description: 'string' }, handlerFor('agreement_counter'), { description: 'Counter a pending proposal with revised terms instead of approving or rejecting. Check status first with agreement_get.' });
60
+ export const agreementGetTool = defineTool('agreement_get', { agreementId: { type: 'string', required: true } }, handlerFor('agreement_get'), { description: 'Read the current proposal status for an agreement (pending, approved, rejected, countered, expired).' });
61
+ export const agreementRevokeTool = defineTool('agreement_revoke', { agreementId: { type: 'string', required: true } }, handlerFor('agreement_revoke'), { description: 'Revoke a pending proposal you created so it can no longer be approved. Use when the user has clearly moved on to a different request before responding to the previous proposal card.' });
62
+ export const agreementFulfillTool = defineTool('agreement_fulfill', { agreementId: { type: 'string', required: true } }, handlerFor('agreement_fulfill'), { description: 'END an agreement you provide — permanently. Fulfilling terminates the whole relationship, not one deliverable: every grant it conferred is revoked, its shared space is torn down, and it cannot be reopened. Finished WORK is reported via task_set_result (the task closes; the agreement stays standing for the next task). Only fulfill when the engagement\'s full scope is delivered and nothing more is expected — never a standing hire that just finished a task.' });
53
63
  // `status` names its four values here because nothing else does: the backend is
54
64
  // the only place the vocabulary was written down, and it now rejects anything
55
65
  // outside it. Every reader (the chat step badge, the SDK's own plan formatter)
@@ -64,8 +74,8 @@ export const taskUpdatePlanStepTool = defineTool('task_update_plan_step', {
64
74
  enum: ['pending', 'in_progress', 'completed', 'skipped'],
65
75
  },
66
76
  result: 'string',
67
- }, handlerFor('task-update-plan-step'), { description: "Update the status of a single step within a task's plan." });
68
- export const taskCreateTool = defineTool('task_create', { agreementId: { type: 'string', required: true }, description: { type: 'string', required: true }, parentTaskId: 'string', assigneeId: { type: 'string', description: 'Explicitly hand this task to one specific agent (e.g. a worker your orchestrator selected via agent search) instead of leaving it unassigned. Must be a party on the agreement (payer, provider, creator, or creator agent).' }, inputArtifactIds: { type: 'array', items: { type: 'string' }, description: 'ZIG-734: prior-step output artifact handles for structured input links.' }, plan: { type: 'object' }, planReviewTiming: planReviewTimingParam, requireMidWorkPlanAck: 'boolean' }, handlerFor('task-spawn'), { description: 'Create a new task under an existing approved agreement. Use when the contract is already in place. Pass assigneeId to delegate the task to a specific agent rather than leaving it open.' });
77
+ }, handlerFor('task_update_plan_step'), { description: "Update the status of a single step within a task's plan." });
78
+ export const taskCreateTool = defineTool('task_create', { agreementId: { type: 'string', required: true }, description: { type: 'string', required: true }, parentTaskId: 'string', assigneeId: { type: 'string', description: 'Explicitly hand this task to one specific agent (e.g. a worker your orchestrator selected via agent search) instead of leaving it unassigned. Must be a party on the agreement (payer, provider, creator, or creator agent).' }, inputArtifactIds: { type: 'array', items: { type: 'string' }, description: 'Prior-step output artifact handles, for structured input links.' }, plan: { type: 'object' }, planReviewTiming: planReviewTimingParam, requireMidWorkPlanAck: 'boolean' }, handlerFor('task_create'), { description: 'Create a new task under an existing approved agreement. Use when the contract is already in place. Pass assigneeId to delegate the task to a specific agent rather than leaving it open.' });
69
79
  export const PROTOCOL_TOOLS = [
70
80
  agreementProposeTool, agreementSubcontractTool,
71
81
  agreementRespondTool, agreementClaimTool, agreementCounterTool, agreementGetTool, agreementRevokeTool,
@@ -7,7 +7,13 @@ import { toolFromCapability } from '../../../tools/capabilityAdapter.js';
7
7
  // allowance in the parity table).
8
8
  export const connectionProxyTool = toolFromCapability(connectionProxyCapability);
9
9
  export const requestConnectionTool = toolFromCapability(requestConnectionCapability);
10
- export const connectionListGrantsTool = defineTool('connection_list_grants', { connectionId: { type: 'string', required: true } }, async (args, ctx) => {
10
+ export const connectionListGrantsTool = defineTool('connection_list_grants', {
11
+ connectionId: {
12
+ type: 'string',
13
+ required: true,
14
+ description: 'The stored connection to list grants for.',
15
+ },
16
+ }, async (args, ctx) => {
11
17
  if (!args['connectionId'])
12
18
  throw new Error('connectionId is required');
13
19
  const operatorKey = ctx?.['operatorKey'];
@@ -28,6 +34,11 @@ export const connectionListGrantsTool = defineTool('connection_list_grants', { c
28
34
  wrapped.body = err.body;
29
35
  throw wrapped;
30
36
  }
37
+ }, {
38
+ // ZIG-1128: this shipped with no description at all — the model was handed a
39
+ // name and a parameter and left to guess what the tool was for. Found by the
40
+ // new SDK tool-text guard.
41
+ description: "List the grants issued to this agent on one stored connection. Returns the grantId that connection_proxy needs. Use it when you have a connectionId but not the grantId — grant_list (scopeKind=connection) is the wider read across every connection.",
31
42
  });
32
43
  export const CONNECTION_TOOLS = [
33
44
  connectionProxyTool,