@ziggs-ai/agent-sdk 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/dist/AgentHost.d.ts +108 -6
  4. package/dist/AgentHost.js +290 -18
  5. package/dist/adapters/McpAdapter.js +10 -12
  6. package/dist/agent/ClaudeSdkAgent.d.ts +9 -0
  7. package/dist/agent/ClaudeSdkAgent.js +127 -26
  8. package/dist/agent/claudeSdkProtocol.d.ts +1 -1
  9. package/dist/agent/claudeSdkProtocol.js +3 -2
  10. package/dist/agent/claudeSdkTools.js +71 -16
  11. package/dist/defineAgent.js +31 -2
  12. package/dist/index.d.ts +7 -3
  13. package/dist/index.js +11 -1
  14. package/dist/pricing/fleetTierByAgentId.gen.d.ts +2 -2
  15. package/dist/pricing/fleetTierByAgentId.gen.js +9 -2
  16. package/dist/runtime/PromptBuilder.js +13 -1
  17. package/dist/server/ConnectionPool.d.ts +19 -0
  18. package/dist/server/ConnectionPool.js +30 -0
  19. package/dist/server/OutboxBuffer.d.ts +1 -1
  20. package/dist/server/ZiggsEffectHandler.d.ts +1 -1
  21. package/dist/server/ZiggsEffectHandler.js +16 -3
  22. package/dist/server/agreements/AgreementService.d.ts +8 -3
  23. package/dist/server/agreements/AgreementService.js +10 -5
  24. package/dist/server/inboxLoop.d.ts +6 -6
  25. package/dist/server/inboxLoop.js +39 -21
  26. package/dist/server/polls/PollRunner.d.ts +36 -1
  27. package/dist/server/polls/PollRunner.js +42 -2
  28. package/dist/server/requestMatch.d.ts +5 -0
  29. package/dist/server/{questMatch.js → requestMatch.js} +2 -2
  30. package/dist/server/runLauncher.js +24 -3
  31. package/dist/server/tasks/TaskService.d.ts +5 -5
  32. package/dist/server/tools/index.d.ts +1 -1
  33. package/dist/server/tools/index.js +1 -1
  34. package/dist/server/tools/tier1/protocolRunner.js +2 -2
  35. package/dist/server/tools/tier1/protocolTools.js +7 -7
  36. package/dist/server/tools/tier2/marketplaceTools.js +2 -2
  37. package/dist/server/tools/tier2/mcpConnectionTools.d.ts +33 -0
  38. package/dist/server/tools/tier2/mcpConnectionTools.js +139 -7
  39. package/dist/server/tools/tier2/paymentTools.d.ts +0 -8
  40. package/dist/server/tools/tier2/paymentTools.js +8 -12
  41. package/dist/tools/ToolManager.d.ts +1 -1
  42. package/dist/tools/ToolManager.js +2 -1
  43. package/dist/tools/defineTool.d.ts +7 -0
  44. package/dist/tools/defineTool.js +12 -0
  45. package/dist/tools/needs.d.ts +54 -0
  46. package/dist/tools/needs.js +84 -0
  47. package/dist/types.d.ts +34 -5
  48. package/package.json +2 -2
  49. package/dist/server/questMatch.d.ts +0 -5
@@ -63,7 +63,7 @@ cachedAgreements) {
63
63
  return null;
64
64
  return agreementService.respond(payload.agreementId, payload.action);
65
65
  case 'agreement-claim': {
66
- // the one claim verb — quest, standing offer, or link invite.
66
+ // the one claim verb — request, standing offer, or link invite.
67
67
  if (!payload.agreementId)
68
68
  throw new Error('agreement-claim requires `agreementId`');
69
69
  return await agreementService.claim(payload.agreementId);
@@ -240,7 +240,7 @@ async function proposeAgreement(services, payload, chatId, _agents, actingAgentI
240
240
  audience: payload.proposedTo === 'org' ? 'org' : 'everyone',
241
241
  });
242
242
  }
243
- throw new Error('On a broadcast, providerId must be your own agent id (a standing offer) or omitted (a quest — the claiming agent fills the open side).');
243
+ throw new Error('On a broadcast, providerId must be your own agent id (a standing offer) or omitted (a request — the claiming agent fills the open side).');
244
244
  }
245
245
  const priced = applyFleetPricingForProtocol('agreement-propose', payload, actingAgentId);
246
246
  const { operation: _op, ...fields } = priced;
@@ -43,10 +43,10 @@ function handlerFor(toolName) {
43
43
  return result;
44
44
  };
45
45
  }
46
- export const agreementProposeTool = defineTool('agreement_propose', { description: { type: 'string', required: true }, proposedTo: { type: 'string', required: true }, engagementKind: { type: 'string', enum: ['hire', 'service'], description: 'Contract type. Defaults to service. For a bilateral trust link, use link_propose.' }, parentAgreementId: 'string', providerId: { type: 'string', description: AGREEMENT_PROPOSE_PROVIDER_ID_DESCRIPTION }, 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. Engagement precedence (posted-first): 1) REUSE an active agreement on matching terms, 2) CLAIM the counterparty\'s listing (marketplace_view → agreement_claim; never counter a listing), 3) POST a quest broadcast here when nothing listed fits, 4) direct-propose ONLY for bespoke terms, renegotiation, or commissioning a named counterparty with no listing — most published agents are claim-only and refuse direct proposals with a pointer at their listing. 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). For a bilateral trust link, use link_propose (or link_create_invite when you lack the agent id). 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 });
47
- 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 });
48
- 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.' });
49
- 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 — the DEFAULT way to engage: terms are already posted, consent is the claim, activation is instant, no negotiation turns. Claims 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; listings are take-it-or-leave-it — never counter one. Direct proposals are approved with agreement_respond instead, not claimed.' });
46
+ export const agreementProposeTool = defineTool('agreement_propose', { description: { type: 'string', required: true }, proposedTo: { type: 'string', required: true }, engagementKind: { type: 'string', enum: ['hire', 'service'], description: 'Contract type. Defaults to service. For a bilateral trust link, use link_propose.' }, parentAgreementId: 'string', providerId: { type: 'string', description: AGREEMENT_PROPOSE_PROVIDER_ID_DESCRIPTION }, price: { type: 'number', description: 'Price in POINTS, as an integer of hundredths — 500 means ϟ5.00. Convert both ways: a human asking for "ϟ2 per task" means 200, not 2. ϟ is the points symbol, not a hundredth.' }, 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. Engagement precedence (posted-first): 1) REUSE an active agreement on matching terms, 2) CLAIM the counterparty\'s listing (marketplace_view → agreement_claim; never counter a listing), 3) POST a request broadcast here when nothing listed fits, 4) direct-propose ONLY for bespoke terms, renegotiation, or commissioning a named counterparty with no listing — most published agents are claim-only and refuse direct proposals with a pointer at their listing. 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 REQUEST any agent may claim; providerId = your own id publishes a STANDING OFFER (claiming is agreement_claim; browsing is marketplace_view). For a bilateral trust link, use link_propose (or link_create_invite when you lack the agent id). 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 });
47
+ 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: 'Price in POINTS, as an integer of hundredths — 500 means ϟ5.00. Convert both ways: a human asking for "ϟ2 per task" means 200, not 2. ϟ is the points symbol, not a hundredth.' }, 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 });
48
+ 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 (requests, 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.' });
49
+ export const agreementClaimTool = defineTool('agreement_claim', { agreementId: { type: 'string', required: true, description: 'The open agreement to claim (request / offer / link invite id)' } }, handlerFor('agreement_claim'), { description: 'Claim an open broadcast agreement by id — the DEFAULT way to engage: terms are already posted, consent is the claim, activation is instant, no negotiation turns. Claims a request (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 requests/offers with marketplace_view; listings are take-it-or-leave-it — never counter one. Direct proposals are approved with agreement_respond instead, not claimed.' });
50
50
  export const taskSetResultTool = defineTool('task_set_result', {
51
51
  taskId: { type: 'string', required: true },
52
52
  // same param name as MCP ziggs_task_set_result / PATCH /tasks/:id/state.
@@ -54,10 +54,10 @@ export const taskSetResultTool = defineTool('task_set_result', {
54
54
  // Same object contract as MCP ({ summary, status, links, … }).
55
55
  result: {
56
56
  type: 'object',
57
- description: 'Outcome payload (summary, status, links, …). Finished work is the task result — never a chat message.',
57
+ description: 'Outcome payload (summary, status, links, …). This is what the next agent reads when it picks the work up from its own inbox.',
58
58
  },
59
- }, 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).' });
60
- 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.' });
59
+ }, handlerFor('task_set_result'), { description: 'Transition a task to its terminal state — completed, failed, or cancelled — and record the result. Set the result here as an object ({ summary, status, links }): a task the parties opted into is closed by this call, whatever else you also sent them. Pass state (not status).' });
60
+ export const agreementCounterTool = defineTool('agreement_counter', { agreementId: { type: 'string', required: true }, price: { type: 'number', description: 'Price in POINTS, as an integer of hundredths — 500 means ϟ5.00. Convert both ways: a human asking for "ϟ2 per task" means 200, not 2. ϟ is the points symbol, not a hundredth.' }, 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.' });
61
61
  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).' });
62
62
  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.' });
63
63
  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.' });
@@ -1,9 +1,9 @@
1
1
  import { MARKETPLACE_CAPABILITIES, marketplaceViewCapability } from '@ziggs-ai/api-client';
2
2
  import { toolFromCapability, toolsFromCapabilities } from '../../../tools/capabilityAdapter.js';
3
- // the marketplace read (quests AND standing offers),
3
+ // the marketplace read (requests AND standing offers),
4
4
  // defined once in api-client's shared capability layer. Publishing is NOT
5
5
  // here: it is the tier-1 verb agreement_propose with proposedTo
6
- // "everyone" | "org" (providerId = own id for an offer, omitted for a quest);
6
+ // "everyone" | "org" (providerId = own id for an offer, omitted for a request);
7
7
  // claiming is agreement_claim.
8
8
  export const marketplaceViewTool = toolFromCapability(marketplaceViewCapability);
9
9
  export const MARKETPLACE_TOOLS = toolsFromCapabilities(MARKETPLACE_CAPABILITIES);
@@ -1,4 +1,37 @@
1
1
  import { type ToolDefinition } from '../../../tools/defineTool.js';
2
2
  export declare const mcpToolsListTool: ToolDefinition;
3
+ /**
4
+ * How much of one MCP tool result a wake will carry.
5
+ *
6
+ * A remote MCP server hands back the upstream API's payload verbatim, and those
7
+ * are sized for programs, not for a context window: GitHub's `list_pull_requests`
8
+ * with no paging argument answered ~150,000 characters. Past the runtime's limit
9
+ * the result stops being a result — the Claude SDK spills an oversized tool
10
+ * result to a file, and a hosted agent has no filesystem tool to read it with, so
11
+ * the wake ends in "the answer was too big and I cannot see it". Same class of
12
+ * problem as an MCP server that ships a 30K-character tool description, one layer
13
+ * later.
14
+ *
15
+ * A bounded answer with a marker beats an unreadable one: the model can act on
16
+ * "truncated, narrow the call", and it cannot act on a file path.
17
+ */
18
+ export declare const MAX_MCP_RESULT_CHARS = 24000;
19
+ /**
20
+ * Tool schemas are bigger than tool results and are read once per wake, so they
21
+ * get their own, larger ceiling — a schema cut in half is worse than useless,
22
+ * because the half that survives looks complete.
23
+ */
24
+ export declare const MAX_MCP_TOOLS_LIST_CHARS = 60000;
25
+ /**
26
+ * Bound one MCP result, telling the model plainly what was cut.
27
+ *
28
+ * Text blocks are cut individually rather than the JSON being sliced, because a
29
+ * sliced JSON string is invalid and a model handed invalid JSON reports the
30
+ * server as broken.
31
+ */
32
+ export declare function boundMcpResult(result: unknown, max?: number): {
33
+ result: unknown;
34
+ truncated?: Record<string, unknown>;
35
+ };
3
36
  export declare const mcpToolCallTool: ToolDefinition;
4
37
  export declare const MCP_CONNECTION_TOOLS: ToolDefinition[];
@@ -4,7 +4,7 @@ import { ConnectionsClient } from '@ziggs-ai/api-client';
4
4
  import { defineTool } from '../../../tools/defineTool.js';
5
5
  import { resolveZiggsGatewayTarget } from '../../../adapters/McpAdapter.js';
6
6
  /**
7
- * ZIG-1104 — calling a tool on a connected MCP server, one shot.
7
+ * Calling a tool on a connected MCP server, one shot.
8
8
  *
9
9
  * `connection_proxy` cannot do this. That path resolves a NAMED connector from
10
10
  * `connection.provider` and checks the action against its fixed action list; a
@@ -89,19 +89,145 @@ export const mcpToolsListTool = defineTool('mcp_tools_list', {
89
89
  type: 'string',
90
90
  description: 'Grant to use. Omit to use the live grant found on that connection.',
91
91
  },
92
+ names: {
93
+ type: 'array',
94
+ items: { type: 'string' },
95
+ description: 'Tool names to return the FULL parameters (inputSchema) for. Omit to get the names and descriptions only — a whole server of schemas is tens of thousands of characters and gets cut, which is worse than not asking.',
96
+ },
92
97
  }, async (args, ctx) => {
93
98
  const t = await resolveTarget(ctx, args['connectionId'], args['grantId']);
99
+ /**
100
+ * Names first, schemas on demand — the same two-step this platform's own MCP
101
+ * surface settled on. One Linear server answers 53 tools and 78,000
102
+ * characters of schema; asking for all of it fills a wake with parameters for
103
+ * tools the agent will not call, and the cut lands mid-schema where the half
104
+ * that survives still looks complete.
105
+ */
106
+ const wanted = args['names']?.filter((n) => typeof n === 'string' && n);
94
107
  const tools = await withClient(ctx, t.connectionId, t.grantId, async (c) => {
95
108
  const res = await c.listTools();
96
- return (res.tools ?? []).map((tool) => ({
109
+ const rows = res.tools ?? [];
110
+ const picked = wanted?.length ? rows.filter((tool) => wanted.includes(tool.name)) : rows;
111
+ return picked.map((tool) => ({
97
112
  name: tool.name,
98
113
  description: tool.description ?? null,
114
+ /**
115
+ * The parameters, which this used to drop.
116
+ *
117
+ * Without them an agent has to GUESS what a tool takes, and a guessed
118
+ * argument is not an error — most servers ignore what they do not know
119
+ * and answer with an empty list. That empty list then reads as "nothing
120
+ * happened": a weekly digest reported one item shipped for a week with
121
+ * fifty, because `status`, `filter.state.name` and `updatedAfter` were
122
+ * all invented and all silently matched nothing.
123
+ */
124
+ ...(wanted?.length ? { inputSchema: tool.inputSchema ?? null } : {}),
99
125
  }));
100
126
  });
101
- return { connectionId: t.connectionId, grantId: t.grantId, tools };
127
+ const missing = wanted?.filter((n) => !tools.some((row) => row.name === n)) ?? [];
128
+ const bounded = boundMcpResult({ tools }, MAX_MCP_TOOLS_LIST_CHARS);
129
+ return {
130
+ connectionId: t.connectionId,
131
+ grantId: t.grantId,
132
+ ...bounded.result,
133
+ ...(bounded.truncated ? { truncated: bounded.truncated } : {}),
134
+ ...(missing.length ? { notFound: missing } : {}),
135
+ ...(wanted?.length
136
+ ? {}
137
+ : {
138
+ note: 'Names and descriptions only. Call again with names:["<tool>"] to get that tool\'s parameters before you pass any arguments — a parameter the server does not declare is usually ignored, and the empty result reads as real emptiness.',
139
+ }),
140
+ };
102
141
  }, {
103
- description: "List the tools a connected MCP server exposes, through the Ziggs gateway — the owner's credential stays server-side. The grant may allow only some of them; a call outside the grant is refused by the gateway, not by you.",
142
+ description: "List the tools a connected MCP server exposes, through the Ziggs gateway. Names and descriptions by default; pass names:[\"<tool>\"] for those tools' full parameters (inputSchema) — the owner's credential stays server-side. Read the schema before calling a tool with arguments: a parameter the server does not declare is usually ignored rather than rejected, so a guessed filter comes back as an empty result that looks like real emptiness. The grant may allow only some tools; a call outside it is refused by the gateway, not by you.",
104
143
  });
144
+ /**
145
+ * How much of one MCP tool result a wake will carry.
146
+ *
147
+ * A remote MCP server hands back the upstream API's payload verbatim, and those
148
+ * are sized for programs, not for a context window: GitHub's `list_pull_requests`
149
+ * with no paging argument answered ~150,000 characters. Past the runtime's limit
150
+ * the result stops being a result — the Claude SDK spills an oversized tool
151
+ * result to a file, and a hosted agent has no filesystem tool to read it with, so
152
+ * the wake ends in "the answer was too big and I cannot see it". Same class of
153
+ * problem as an MCP server that ships a 30K-character tool description, one layer
154
+ * later.
155
+ *
156
+ * A bounded answer with a marker beats an unreadable one: the model can act on
157
+ * "truncated, narrow the call", and it cannot act on a file path.
158
+ */
159
+ export const MAX_MCP_RESULT_CHARS = 24_000;
160
+ /**
161
+ * Tool schemas are bigger than tool results and are read once per wake, so they
162
+ * get their own, larger ceiling — a schema cut in half is worse than useless,
163
+ * because the half that survives looks complete.
164
+ */
165
+ export const MAX_MCP_TOOLS_LIST_CHARS = 60_000;
166
+ const TRUNCATION_ADVICE = 'Narrow the call instead of repeating it: most servers take paging (perPage/page) ' +
167
+ 'or filter arguments, and a named target usually has a narrower tool than a list.';
168
+ /**
169
+ * Bound one MCP result, telling the model plainly what was cut.
170
+ *
171
+ * Text blocks are cut individually rather than the JSON being sliced, because a
172
+ * sliced JSON string is invalid and a model handed invalid JSON reports the
173
+ * server as broken.
174
+ */
175
+ export function boundMcpResult(result, max = MAX_MCP_RESULT_CHARS) {
176
+ const whole = JSON.stringify(result) ?? '';
177
+ if (whole.length <= max)
178
+ return { result };
179
+ const content = result?.content;
180
+ if (!Array.isArray(content)) {
181
+ return {
182
+ result: { text: whole.slice(0, max) },
183
+ truncated: {
184
+ originalChars: whole.length,
185
+ keptChars: max,
186
+ note: `Result was ${whole.length} characters and is cut to ${max}. ${TRUNCATION_ADVICE}`,
187
+ },
188
+ };
189
+ }
190
+ const kept = [];
191
+ let used = 0;
192
+ let dropped = 0;
193
+ for (const block of content) {
194
+ const text = block?.text;
195
+ if (typeof text !== 'string') {
196
+ const size = (JSON.stringify(block) ?? '').length;
197
+ if (used + size > max) {
198
+ dropped += 1;
199
+ continue;
200
+ }
201
+ used += size;
202
+ kept.push(block);
203
+ continue;
204
+ }
205
+ const room = max - used;
206
+ if (room <= 0) {
207
+ dropped += 1;
208
+ continue;
209
+ }
210
+ if (text.length <= room) {
211
+ used += text.length;
212
+ kept.push(block);
213
+ continue;
214
+ }
215
+ kept.push({
216
+ ...block,
217
+ text: `${text.slice(0, room)}\n\n[…cut here: this block was ${text.length} characters]`,
218
+ });
219
+ used = max;
220
+ }
221
+ return {
222
+ result: { ...result, content: kept },
223
+ truncated: {
224
+ originalChars: whole.length,
225
+ keptChars: used,
226
+ droppedBlocks: dropped,
227
+ note: `Result was ${whole.length} characters, over the ${max}-character limit, so it is cut. What you have is the beginning, not the whole answer — do not report it as complete. ${TRUNCATION_ADVICE}`,
228
+ },
229
+ };
230
+ }
105
231
  export const mcpToolCallTool = defineTool('mcp_tool_call', {
106
232
  tool: {
107
233
  type: 'string',
@@ -125,12 +251,18 @@ export const mcpToolCallTool = defineTool('mcp_tool_call', {
125
251
  if (!name)
126
252
  throw new Error('tool is required');
127
253
  const t = await resolveTarget(ctx, args['connectionId'], args['grantId']);
128
- const result = await withClient(ctx, t.connectionId, t.grantId, (c) => c.callTool({
254
+ const raw = await withClient(ctx, t.connectionId, t.grantId, (c) => c.callTool({
129
255
  name,
130
256
  arguments: args['args'] ?? {},
131
257
  }));
132
- return { connectionId: t.connectionId, tool: name, result };
258
+ const bounded = boundMcpResult(raw);
259
+ return {
260
+ connectionId: t.connectionId,
261
+ tool: name,
262
+ result: bounded.result,
263
+ ...(bounded.truncated ? { truncated: bounded.truncated } : {}),
264
+ };
133
265
  }, {
134
- description: "Call one tool on a connected MCP server through the Ziggs gateway. Use this for a connection whose provider is a remote MCP server — connection_proxy is for named connectors (github, jira) and refuses these with \"Unknown provider: mcp\". The gateway injects the owner's token upstream and checks the tool name against your grant, so you never hold the credential. Discover names with mcp_tools_list, or read allowed_actions off the grant in grant_list.",
266
+ description: "Call one tool on a connected MCP server through the Ziggs gateway. Use this for a connection whose provider is a remote MCP server — connection_proxy is for named connectors (github, jira) and refuses these with \"Unknown provider: mcp\". The gateway injects the owner's token upstream and checks the tool name against your grant, so you never hold the credential. Discover names with mcp_tools_list, or read allowed_actions off the grant in grant_list. Results are bounded: a payload over the limit comes back cut, with a `truncated` field saying so — pass the server's paging or filter arguments rather than repeating the call.",
135
267
  });
136
268
  export const MCP_CONNECTION_TOOLS = [mcpToolsListTool, mcpToolCallTool];
@@ -1,11 +1,3 @@
1
1
  import type { ToolDefinition } from '../../../tools/defineTool.js';
2
2
  export declare const paymentBalanceTool: ToolDefinition;
3
- export declare const paymentTransferTool: ToolDefinition;
4
- export declare const paymentWaitForApprovalTool: ToolDefinition;
5
- export declare const paymentHoldTool: ToolDefinition;
6
- export declare const paymentReleaseTool: ToolDefinition;
7
- export declare const paymentResolveWalletTool: ToolDefinition;
8
- export declare const paymentIssueGrantTool: ToolDefinition;
9
- export declare const paymentAttenuateGrantTool: ToolDefinition;
10
- export declare const paymentRevokeGrantTool: ToolDefinition;
11
3
  export declare const PAYMENT_TOOLS: ToolDefinition[];
@@ -1,16 +1,12 @@
1
- import { PAYMENT_CAPABILITIES, paymentBalanceCapability, paymentTransferCapability, paymentWaitForApprovalCapability, paymentHoldCapability, paymentReleaseCapability, paymentResolveWalletCapability, paymentIssueGrantCapability, paymentAttenuateGrantCapability, paymentRevokeGrantCapability, } from '@ziggs-ai/api-client';
1
+ import { PAYMENT_CAPABILITIES, paymentBalanceCapability } from '@ziggs-ai/api-client';
2
2
  import { toolFromCapability, toolsFromCapabilities } from '../../../tools/capabilityAdapter.js';
3
3
  // the wallet toolset is defined once in api-client's shared
4
- // capability layer; this file only adapts it onto the SDK surface. Safety is
5
- // unchanged: money-moving calls stay policy-gated server-side, and there is
6
- // deliberately NO approve/decide tool on the agent surface.
4
+ // capability layer; this file only adapts it onto the SDK surface.
5
+ //
6
+ // The movement verbs are gone from both agent surfaces —
7
+ // transfer, hold, release, wait_for_approval, resolve_wallet, and the three
8
+ // grant mutations. Points are a meter, and an agent does not move the meter on
9
+ // its own. Agreements still settle in points server-side, so nothing an agent
10
+ // needs in order to be paid went with them. Reading your own balance survives.
7
11
  export const paymentBalanceTool = toolFromCapability(paymentBalanceCapability);
8
- export const paymentTransferTool = toolFromCapability(paymentTransferCapability);
9
- export const paymentWaitForApprovalTool = toolFromCapability(paymentWaitForApprovalCapability);
10
- export const paymentHoldTool = toolFromCapability(paymentHoldCapability);
11
- export const paymentReleaseTool = toolFromCapability(paymentReleaseCapability);
12
- export const paymentResolveWalletTool = toolFromCapability(paymentResolveWalletCapability);
13
- export const paymentIssueGrantTool = toolFromCapability(paymentIssueGrantCapability);
14
- export const paymentAttenuateGrantTool = toolFromCapability(paymentAttenuateGrantCapability);
15
- export const paymentRevokeGrantTool = toolFromCapability(paymentRevokeGrantCapability);
16
12
  export const PAYMENT_TOOLS = toolsFromCapabilities(PAYMENT_CAPABILITIES);
@@ -1,5 +1,5 @@
1
1
  import { ToolProvider } from './ToolProvider.js';
2
- import type { ToolDefinition } from './defineTool.js';
2
+ import { type ToolDefinition } from './defineTool.js';
3
3
  export declare class ToolManager extends ToolProvider {
4
4
  private tools;
5
5
  constructor();
@@ -1,5 +1,6 @@
1
1
  import AjvModule from 'ajv';
2
2
  import { ToolProvider } from './ToolProvider.js';
3
+ import { toolName } from './defineTool.js';
3
4
  const AjvClass = AjvModule.default ?? AjvModule;
4
5
  const ajv = new AjvClass({ allErrors: true, strict: false });
5
6
  function makeError(message, type, extra) {
@@ -49,7 +50,7 @@ export class ToolManager extends ToolProvider {
49
50
  throw makeError('Tool must have a schema', 'invalid_tool');
50
51
  if (typeof tool.handler !== 'function')
51
52
  throw makeError('Tool must have a handler function', 'invalid_tool');
52
- const name = (tool.schema.function?.['name'] || tool.schema['name']);
53
+ const name = toolName(tool);
53
54
  if (!name)
54
55
  throw makeError('Tool schema must have a name', 'invalid_tool');
55
56
  this.tools.set(name, tool);
@@ -24,4 +24,11 @@ export interface DefineToolOptions {
24
24
  isAgreementCreation?: boolean;
25
25
  isGenericFallback?: boolean;
26
26
  }
27
+ /**
28
+ * The name a tool registers under. `defineTool` always writes
29
+ * `schema.function.name`, but hand-built and capability-adapted schemas have put
30
+ * it at the top level too, so both are read — in one place, rather than at every
31
+ * call site that needs to identify a tool.
32
+ */
33
+ export declare function toolName(tool: ToolDefinition | undefined): string | undefined;
27
34
  export declare function defineTool(name: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>, context: Record<string, unknown>) => Promise<unknown>, options?: DefineToolOptions): ToolDefinition;
@@ -43,6 +43,18 @@ function convertObject(fields) {
43
43
  result['required'] = required;
44
44
  return result;
45
45
  }
46
+ /**
47
+ * The name a tool registers under. `defineTool` always writes
48
+ * `schema.function.name`, but hand-built and capability-adapted schemas have put
49
+ * it at the top level too, so both are read — in one place, rather than at every
50
+ * call site that needs to identify a tool.
51
+ */
52
+ export function toolName(tool) {
53
+ const fromFunction = tool?.schema?.function?.['name'];
54
+ const fromTop = tool?.schema?.['name'];
55
+ const name = fromFunction ?? fromTop;
56
+ return typeof name === 'string' && name ? name : undefined;
57
+ }
46
58
  export function defineTool(name, params, handler, options = {}) {
47
59
  if (!name || typeof name !== 'string')
48
60
  throw new Error('defineTool: name is required');
@@ -0,0 +1,54 @@
1
+ /**
2
+ * needs.ts — what an agent cannot work without, declared once.
3
+ *
4
+ * An agent is otherwise two hand-written lists — instructions and tools — with
5
+ * nothing checking they agree, so a prompt can promise work the toolset has no
6
+ * way to do and the mismatch only surfaces on a real hire. A third list to
7
+ * compare against the other two would drift the same way, so the declaration
8
+ * DERIVES the tools instead. The author never writes that part of the list, so
9
+ * the mismatch cannot be written.
10
+ *
11
+ * Scope is agents built on this SDK.
12
+ */
13
+ import { type ToolDefinition } from './defineTool.js';
14
+ /**
15
+ * A remote MCP server is stored as `provider: 'mcp'` and goes through the
16
+ * gateway. `connection_proxy` resolves a NAMED connector and refuses it with
17
+ * "Unknown provider: mcp" — the second wall linear-janitor hit.
18
+ */
19
+ export declare const MCP_PROVIDER = "mcp";
20
+ /**
21
+ * One shape for now, the one we have actually hit. A second kind means a case in
22
+ * {@link toolsForNeed}: a need nothing derives tools from is a need nothing
23
+ * enforces.
24
+ */
25
+ export interface ConnectionNeed {
26
+ /** Provider, e.g. 'mcp' or a named connector like 'github'. */
27
+ connection: string;
28
+ }
29
+ export type AgentNeed = ConnectionNeed;
30
+ /** Reads a manifest's `needs` field defensively — it crosses a package edge. */
31
+ export declare function normalizeNeeds(input: unknown): AgentNeed[];
32
+ /**
33
+ * The tools a stated need implies. `connection_list_grants` rides every need
34
+ * because seeing the grant is half of using it: the janitor's first failure was
35
+ * an agent that held a Linear grant and had no way to notice.
36
+ */
37
+ export declare function toolsForNeed(need: AgentNeed): ToolDefinition[];
38
+ /** Deduped by tool name, so overlapping needs (and a hand-listed tool) merge. */
39
+ export declare function toolsForNeeds(needs: AgentNeed[]): ToolDefinition[];
40
+ /** One line naming what is missing, for a refusal a human can act on. */
41
+ export declare function describeNeed(need: AgentNeed): string;
42
+ /**
43
+ * Which declared needs have no live grant behind them.
44
+ *
45
+ * `providers` are the providers of the agent's live connection grants. The label
46
+ * a grant carries is the provider, sometimes suffixed with the owner's account
47
+ * ("github — acme/infra"), so the provider is the part before the separator.
48
+ */
49
+ export declare function unmetNeeds(needs: AgentNeed[], providers: Array<string | null | undefined>): AgentNeed[];
50
+ /**
51
+ * The refusal itself. Names the missing access and who can fix it, because the
52
+ * agent cannot: issuing a connection grant is the connection owner's act.
53
+ */
54
+ export declare function unmetNeedsMessage(unmet: AgentNeed[]): string;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * needs.ts — what an agent cannot work without, declared once.
3
+ *
4
+ * An agent is otherwise two hand-written lists — instructions and tools — with
5
+ * nothing checking they agree, so a prompt can promise work the toolset has no
6
+ * way to do and the mismatch only surfaces on a real hire. A third list to
7
+ * compare against the other two would drift the same way, so the declaration
8
+ * DERIVES the tools instead. The author never writes that part of the list, so
9
+ * the mismatch cannot be written.
10
+ *
11
+ * Scope is agents built on this SDK.
12
+ */
13
+ import { toolName } from './defineTool.js';
14
+ import { connectionListGrantsTool, connectionProxyTool } from '../server/tools/tier2/connectionTools.js';
15
+ import { MCP_CONNECTION_TOOLS } from '../server/tools/tier2/mcpConnectionTools.js';
16
+ /**
17
+ * A remote MCP server is stored as `provider: 'mcp'` and goes through the
18
+ * gateway. `connection_proxy` resolves a NAMED connector and refuses it with
19
+ * "Unknown provider: mcp" — the second wall linear-janitor hit.
20
+ */
21
+ export const MCP_PROVIDER = 'mcp';
22
+ /** Reads a manifest's `needs` field defensively — it crosses a package edge. */
23
+ export function normalizeNeeds(input) {
24
+ if (!Array.isArray(input))
25
+ return [];
26
+ const out = [];
27
+ for (const raw of input) {
28
+ const connection = raw?.connection;
29
+ if (typeof connection === 'string' && connection.trim()) {
30
+ out.push({ connection: connection.trim() });
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * The tools a stated need implies. `connection_list_grants` rides every need
37
+ * because seeing the grant is half of using it: the janitor's first failure was
38
+ * an agent that held a Linear grant and had no way to notice.
39
+ */
40
+ export function toolsForNeed(need) {
41
+ return need.connection === MCP_PROVIDER
42
+ ? [connectionListGrantsTool, ...MCP_CONNECTION_TOOLS]
43
+ : [connectionListGrantsTool, connectionProxyTool];
44
+ }
45
+ /** Deduped by tool name, so overlapping needs (and a hand-listed tool) merge. */
46
+ export function toolsForNeeds(needs) {
47
+ const byName = new Map();
48
+ for (const need of needs) {
49
+ for (const tool of toolsForNeed(need)) {
50
+ const name = toolName(tool);
51
+ if (name)
52
+ byName.set(name, tool);
53
+ }
54
+ }
55
+ return [...byName.values()];
56
+ }
57
+ /** One line naming what is missing, for a refusal a human can act on. */
58
+ export function describeNeed(need) {
59
+ return need.connection === MCP_PROVIDER
60
+ ? 'a connection to an MCP server'
61
+ : `a ${need.connection} connection`;
62
+ }
63
+ /**
64
+ * Which declared needs have no live grant behind them.
65
+ *
66
+ * `providers` are the providers of the agent's live connection grants. The label
67
+ * a grant carries is the provider, sometimes suffixed with the owner's account
68
+ * ("github — acme/infra"), so the provider is the part before the separator.
69
+ */
70
+ export function unmetNeeds(needs, providers) {
71
+ const held = new Set(providers
72
+ .map((p) => String(p ?? '').split('—')[0].trim().toLowerCase())
73
+ .filter(Boolean));
74
+ return needs.filter((n) => !held.has(n.connection.toLowerCase()));
75
+ }
76
+ /**
77
+ * The refusal itself. Names the missing access and who can fix it, because the
78
+ * agent cannot: issuing a connection grant is the connection owner's act.
79
+ */
80
+ export function unmetNeedsMessage(unmet) {
81
+ const list = unmet.map(describeNeed).join(', ');
82
+ return (`Cannot start this task: no live grant for ${list}. ` +
83
+ 'Ask the connection owner to issue one for this agent, then re-assign the task.');
84
+ }
package/dist/types.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * One outcome vocabulary, one ctx shape, one transition signature.
5
5
  * Every state — parked or thinking — re-enters with exactly one Outcome.
6
6
  */
7
+ import type { AgentNeed } from './tools/needs.js';
7
8
  export interface AgreementRef {
8
9
  agreementId: string;
9
10
  parties?: {
@@ -202,7 +203,7 @@ export interface Ctx {
202
203
  * Recent wake timestamps (epoch ms) for this lane (claude-sdk cognition
203
204
  * only). Backstop circuit breaker: if a lane wakes faster than any real
204
205
  * traffic could drive it, the brain skips the LLM instead of burning the
205
- * fleet key on a wake storm.
206
+ * key on a wake storm.
206
207
  */
207
208
  recentDeliveries?: number[];
208
209
  }
@@ -327,11 +328,23 @@ export interface LlmResponse {
327
328
  completionTokens?: number;
328
329
  };
329
330
  }
331
+ /**
332
+ * One participant on the snapshot roster. `kind` is the declared holder kind the
333
+ * server projection stamped, so a consumer reads what a participant IS instead
334
+ * of inferring it from which array the row arrived in. Optional because a
335
+ * backend that predates it simply omits the field.
336
+ */
337
+ export interface SnapshotParticipant {
338
+ role?: string;
339
+ kind?: string;
340
+ isYou?: boolean;
341
+ [key: string]: unknown;
342
+ }
330
343
  export interface ContextSnapshot {
331
344
  history: HistoryEntry[];
332
345
  agreements: unknown[];
333
- agents: unknown[];
334
- users: unknown[];
346
+ agents: SnapshotParticipant[];
347
+ users: SnapshotParticipant[];
335
348
  timelineSummary?: string | null;
336
349
  latestSequence?: string | null;
337
350
  unavailable?: boolean;
@@ -471,10 +484,17 @@ export type Effect = {
471
484
  stateId?: string;
472
485
  /** Replay-stable write keys for this wake — write-capable tools derive their idempotency key from here, never from the LLM. */
473
486
  writeKeys?: DeliveryWriteKeys;
474
- } | {
487
+ }
488
+ /**
489
+ * `receiverId` is optional: in a room with one other participant the backend
490
+ * resolves the recipient from the room itself, which is the only answer
491
+ * available when the wake's sender belongs to a DIFFERENT room.
492
+ * Name a receiver whenever the room holds more than two parties.
493
+ */
494
+ | {
475
495
  kind: 'send-message';
476
496
  sessionId: string;
477
- receiverId: string;
497
+ receiverId?: string;
478
498
  text: string;
479
499
  messageId?: string;
480
500
  } | {
@@ -534,6 +554,13 @@ export interface DefineAgentInput {
534
554
  initial: string;
535
555
  states: Record<string, State>;
536
556
  tools?: unknown[];
557
+ /**
558
+ * Access this agent cannot work without. Declaring a need PRODUCES the tools
559
+ * for it — do not also hand-list them — and makes work that arrives without
560
+ * the grant fail at task start with the missing thing named, instead of
561
+ * part-way through a real hire.
562
+ */
563
+ needs?: AgentNeed[];
537
564
  services?: ServicesConfig;
538
565
  operatorKey?: string;
539
566
  taskTools?: 'all' | 'none' | string[];
@@ -557,6 +584,8 @@ export interface AgentConfig {
557
584
  systemPrompt?: string;
558
585
  specialization?: string;
559
586
  tools: unknown[];
587
+ /** Normalized declared needs — see DefineAgentInput.needs. */
588
+ needs?: AgentNeed[];
560
589
  model: string;
561
590
  workflow: Workflow;
562
591
  services?: ServicesConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,7 +26,7 @@
26
26
  "@anthropic-ai/claude-agent-sdk": "^0.3.204",
27
27
  "@anthropic-ai/sdk": "^0.110.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",
29
- "@ziggs-ai/api-client": "0.10.0",
29
+ "@ziggs-ai/api-client": "0.11.0",
30
30
  "ajv": "^8.17.1",
31
31
  "dotenv": "^17.2.3",
32
32
  "openai": "^4.0.0",