@ziggs-ai/agent-sdk 0.11.1 → 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 (33) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/AgentHost.d.ts +27 -5
  3. package/dist/AgentHost.js +106 -14
  4. package/dist/agent/claudeSdkProtocol.d.ts +1 -1
  5. package/dist/agent/claudeSdkProtocol.js +3 -2
  6. package/dist/agent/claudeSdkTools.js +46 -10
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +8 -1
  9. package/dist/runtime/PromptBuilder.js +13 -1
  10. package/dist/server/ConnectionPool.d.ts +19 -0
  11. package/dist/server/ConnectionPool.js +30 -0
  12. package/dist/server/OutboxBuffer.d.ts +1 -1
  13. package/dist/server/ZiggsEffectHandler.d.ts +1 -1
  14. package/dist/server/ZiggsEffectHandler.js +6 -2
  15. package/dist/server/agreements/AgreementService.d.ts +8 -3
  16. package/dist/server/agreements/AgreementService.js +10 -5
  17. package/dist/server/inboxLoop.d.ts +6 -6
  18. package/dist/server/inboxLoop.js +31 -18
  19. package/dist/server/requestMatch.d.ts +5 -0
  20. package/dist/server/{questMatch.js → requestMatch.js} +2 -2
  21. package/dist/server/runLauncher.js +24 -3
  22. package/dist/server/tools/index.d.ts +1 -1
  23. package/dist/server/tools/index.js +1 -1
  24. package/dist/server/tools/tier1/protocolRunner.js +2 -2
  25. package/dist/server/tools/tier1/protocolTools.js +7 -7
  26. package/dist/server/tools/tier2/marketplaceTools.js +2 -2
  27. package/dist/server/tools/tier2/mcpConnectionTools.d.ts +33 -0
  28. package/dist/server/tools/tier2/mcpConnectionTools.js +138 -6
  29. package/dist/server/tools/tier2/paymentTools.d.ts +0 -8
  30. package/dist/server/tools/tier2/paymentTools.js +8 -12
  31. package/dist/types.d.ts +23 -4
  32. package/package.json +2 -2
  33. package/dist/server/questMatch.d.ts +0 -5
@@ -15,7 +15,7 @@ const emptySnapshot = (agentId, chatMissing = false) => ({
15
15
  history: [],
16
16
  agreements: [],
17
17
  users: [],
18
- agents: [{ agentId, role: 'self', isYou: true }],
18
+ agents: [{ agentId, role: 'self', isYou: true, kind: 'agent' }],
19
19
  latestSequence: null,
20
20
  ...(chatMissing ? { chatMissing: true } : {}),
21
21
  });
@@ -26,7 +26,11 @@ async function buildSnapshot(deps, chatId, agentId, opts = {}) {
26
26
  const snap = await deps.contextReadClient.snapshot(chatId, {
27
27
  maxMessages: opts.limit,
28
28
  });
29
- return snap;
29
+ // Built rather than cast: the roster rows are typed on both sides now (they
30
+ // carry the declared participant kind), and only `history` is loose on the
31
+ // wire, so a whole-object cast would be asserting past the one field that
32
+ // actually needs it.
33
+ return { ...snap, history: snap.history };
30
34
  }
31
35
  catch (err) {
32
36
  // Defensive: server normally signals a dead chat as 200 + chatMissing, but
@@ -1,4 +1,4 @@
1
- import { type Agreement, type ClaimedKind, type ProposeAgreementData, type ProposeDirectInput, type ProposeBroadcastInput, type PublishOfferPayload, type DelegateAgreementData, type CounterAgreementData, type CreateAgreementBody, type ListAgreementsFilters, type GetMyAgreementsFilters, type ChatLinkType, type ArtifactLinkType, type UserRole } from '@ziggs-ai/api-client';
1
+ import { type Agreement, type ClaimedKind, type ProposeAgreementData, type ProposeDirectInput, type ProposeBroadcastInput, type PublishOfferPayload, type DelegateAgreementData, type CounterAgreementData, type CreateLinkBody, type ListAgreementsFilters, type GetMyAgreementsFilters, type ChatLinkType, type ArtifactLinkType, type UserRole } from '@ziggs-ai/api-client';
2
2
  export declare class AgreementService {
3
3
  private creds;
4
4
  constructor(operatorKey: string, agentId: string);
@@ -20,7 +20,7 @@ export declare class AgreementService {
20
20
  delegate(payload: DelegateAgreementData): Promise<Agreement>;
21
21
  respond(agreementId: string, action: 'approve' | 'reject'): Promise<Agreement>;
22
22
  /**
23
- * claim any open broadcast (quest, standing offer, hand-off,
23
+ * claim any open broadcast (request, standing offer, hand-off,
24
24
  * link invite). Routing (offer vs agreement claim endpoint) is decided in
25
25
  * the shared flow; `kind` is the api-client ClaimedKind so this signature
26
26
  * can't drift from the flow again (a 'hand-off' addition broke the build).
@@ -45,7 +45,12 @@ export declare class AgreementService {
45
45
  */
46
46
  mine(filters?: GetMyAgreementsFilters): Promise<Agreement[]>;
47
47
  get(agreementId: string): Promise<Agreement | null>;
48
- create(body: CreateAgreementBody): Promise<{
48
+ /**
49
+ * Create a trust link — direct to one agent, or an open invite. Work
50
+ * agreements are created by `propose` / the marketplace claim routes, which
51
+ * run the term normalisation and consent gates this rail never had.
52
+ */
53
+ createLink(body: CreateLinkBody): Promise<{
49
54
  ok: boolean;
50
55
  agreement: Agreement;
51
56
  }>;
@@ -1,4 +1,4 @@
1
- import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, claimOpenAgreement, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi, createAgreement, revokeAgreement, fulfillAgreement, linkAgreementToChat, getChatsForAgreement, linkArtifactToAgreement, getArtifactsForAgreement, linkUserToAgreement, getUsersForAgreement, } from '@ziggs-ai/api-client';
1
+ import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, claimOpenAgreement, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi, createLink, revokeAgreement, fulfillAgreement, linkAgreementToChat, getChatsForAgreement, linkArtifactToAgreement, getArtifactsForAgreement, linkUserToAgreement, getUsersForAgreement, } from '@ziggs-ai/api-client';
2
2
  export class AgreementService {
3
3
  creds;
4
4
  constructor(operatorKey, agentId) {
@@ -38,7 +38,7 @@ export class AgreementService {
38
38
  return respondToAgreement(agreementId, action, this.creds);
39
39
  }
40
40
  /**
41
- * claim any open broadcast (quest, standing offer, hand-off,
41
+ * claim any open broadcast (request, standing offer, hand-off,
42
42
  * link invite). Routing (offer vs agreement claim endpoint) is decided in
43
43
  * the shared flow; `kind` is the api-client ClaimedKind so this signature
44
44
  * can't drift from the flow again (a 'hand-off' addition broke the build).
@@ -51,7 +51,7 @@ export class AgreementService {
51
51
  * agent; the target owner approves. Folds the old request_link tool.
52
52
  */
53
53
  async requestLink(providerId, description) {
54
- const { agreement } = await createAgreement({ engagementKind: 'link', providerId, ...(description ? { description } : {}) }, this.creds);
54
+ const { agreement } = await createLink({ targetAgentId: providerId, ...(description ? { description } : {}) }, this.creds);
55
55
  return agreement;
56
56
  }
57
57
  counter(agreementId, counter) {
@@ -75,8 +75,13 @@ export class AgreementService {
75
75
  get(agreementId) {
76
76
  return getAgreement(agreementId, this.creds);
77
77
  }
78
- create(body) {
79
- return createAgreement(body, this.creds);
78
+ /**
79
+ * Create a trust link — direct to one agent, or an open invite. Work
80
+ * agreements are created by `propose` / the marketplace claim routes, which
81
+ * run the term normalisation and consent gates this rail never had.
82
+ */
83
+ createLink(body) {
84
+ return createLink(body, this.creds);
80
85
  }
81
86
  revoke(agreementId) {
82
87
  return revokeAgreement(agreementId, this.creds);
@@ -1,4 +1,4 @@
1
- import { type InboxQuestRef, type InboxTaskRef, type Creds } from '@ziggs-ai/api-client';
1
+ import { type InboxRequestRef, type InboxTaskRef, type Creds } from '@ziggs-ai/api-client';
2
2
  import { SeenMessages } from './SeenMessages.js';
3
3
  /**
4
4
  * Per-agent inbox long-poll loop (delivery law, phase 4). This is the ONLY way
@@ -27,11 +27,11 @@ export interface InboxLoopDeps {
27
27
  */
28
28
  onTask?: (task: InboxTaskRef) => Promise<void>;
29
29
  /**
30
- * A marketplace quest doorbell. The host MUST triage with a plain
30
+ * A marketplace request doorbell. The host MUST triage with a plain
31
31
  * string compare and return without an LLM wake on miss — otherwise every
32
- * public quest burns a full Claude turn per published supplier.
32
+ * public request burns a full Claude turn per published supplier.
33
33
  */
34
- onQuest?: (quest: InboxQuestRef) => Promise<void>;
34
+ onRequest?: (request: InboxRequestRef) => Promise<void>;
35
35
  label?: string;
36
36
  }
37
37
  export interface InboxPassResult {
@@ -41,8 +41,8 @@ export interface InboxPassResult {
41
41
  resourceEvents: number;
42
42
  /** Assigned open tasks delivered this pass. */
43
43
  tasksDelivered: number;
44
- /** Quest refs triaged this pass — includes misses that only ack. */
45
- questsTriaged: number;
44
+ /** Request refs triaged this pass — includes misses that only ack. */
45
+ requestsTriaged: number;
46
46
  /** True when the watermark advanced this pass. */
47
47
  acked: boolean;
48
48
  /** True when there was more mail than one envelope carries. */
@@ -83,7 +83,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
83
83
  let messagesDelivered = 0;
84
84
  let resourceEvents = 0;
85
85
  let tasksDelivered = 0;
86
- let questsTriaged = 0;
86
+ let requestsTriaged = 0;
87
87
  let failures = 0;
88
88
  // a handover that failed must not be acked, and must not be
89
89
  // remembered as seen — otherwise the redelivery the held watermark buys is
@@ -107,10 +107,16 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
107
107
  // make a badly backed-up agent replay its whole backlog forever.
108
108
  warn(label, 'inbox deliveriesCapped (more mail than one envelope carries — the tail follows on the next pass)');
109
109
  }
110
- // Oldest delivery per chat: where to start reading that chat's messages.
110
+ // The single-assign law: a row is MINE to act on iff it carries my stamp.
111
+ // Everything else in the merged view (a sibling carrier's mail, the owner's
112
+ // needs-human rows, granted ambient lanes) is context the host may read on
113
+ // demand — never a wake, never mine to ack as handled.
114
+ const mine = (d) => d.assigneeId === deps.ownAgentId;
115
+ // Oldest ASSIGNED delivery per chat: where to start reading that chat's
116
+ // messages. Ambient chat rows must not drag the read window earlier.
111
117
  const earliestByChat = new Map();
112
118
  for (const d of envelope.deliveries) {
113
- if (!d.chatId)
119
+ if (!d.chatId || !mine(d))
114
120
  continue;
115
121
  const seen = earliestByChat.get(d.chatId);
116
122
  if (!seen || d.ts < seen)
@@ -126,10 +132,14 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
126
132
  // Everything that is not chat text is handed over as a resource event, from
127
133
  // the delivery itself — the reference already carries the ids the host needs,
128
134
  // so nothing has to be re-derived to name what changed.
129
- // Quests must never take this path — even if a pre-cut server
135
+ // Requests must never take this path — even if a pre-cut server
130
136
  // left them in `deliveries`, skip so they cannot wake the LLM before triage.
137
+ // Unassigned rows never take it either: waking the brain on a sibling's
138
+ // mail is the N-wakes-per-event cost the party-owned inbox exists to kill.
131
139
  for (const d of envelope.deliveries) {
132
- if (d.kind === 'message' || d.kind === 'quest')
140
+ if (d.kind === 'message' || d.kind === 'request')
141
+ continue;
142
+ if (!mine(d))
133
143
  continue;
134
144
  const key = `delivery:${d.kind}:${d.resourceId}:${d.ts}`;
135
145
  if (deps.seen.has(key))
@@ -169,26 +179,26 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
169
179
  if (envelope.truncatedTasks) {
170
180
  warn(label, `inbox truncatedTasks=${envelope.truncatedTasks} (more assigned tasks than one pass surfaces — they appear as earlier ones close)`);
171
181
  }
172
- // quest channel. Triage is the host's job (exact string compare);
182
+ // request channel. Triage is the host's job (exact string compare);
173
183
  // a miss must still count as handled so the watermark can advance with 0 tokens.
174
- for (const quest of envelope.questsAwaitingMe ?? []) {
175
- const key = `quest:${quest.agreementId}:${quest.ts}`;
184
+ for (const request of envelope.requestsAwaitingMe ?? []) {
185
+ const key = `request:${request.agreementId}:${request.ts}`;
176
186
  if (deps.seen.has(key))
177
187
  continue;
178
188
  deps.seen.mark(key);
179
- if (deps.onQuest) {
180
- const handled = await attempt(`quest ${quest.agreementId}`, () => deps.onQuest(quest), () => deps.seen.unmark(key));
189
+ if (deps.onRequest) {
190
+ const handled = await attempt(`request ${request.agreementId}`, () => deps.onRequest(request), () => deps.seen.unmark(key));
181
191
  if (handled)
182
- questsTriaged++;
192
+ requestsTriaged++;
183
193
  }
184
194
  else {
185
195
  // No handler → still count as handled (ack) so an old host cannot wedge
186
- // forever on quest mail it does not understand.
187
- questsTriaged++;
196
+ // forever on request mail it does not understand.
197
+ requestsTriaged++;
188
198
  }
189
199
  }
190
- if (envelope.truncatedQuests) {
191
- warn(label, `inbox truncatedQuests=${envelope.truncatedQuests} (more quests than one pass surfaces — the tail follows on the next pass)`);
200
+ if (envelope.truncatedRequests) {
201
+ warn(label, `inbox truncatedRequests=${envelope.truncatedRequests} (more requests than one pass surfaces — the tail follows on the next pass)`);
192
202
  }
193
203
  // Pending decisions are standing state, not deliveries: they keep appearing
194
204
  // until answered, so dedup them the same way tasks are deduped.
@@ -226,10 +236,13 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
226
236
  runtimeLog.error(label, `acking a window that failed ${failures} handover(s) — giving up on redelivery to keep the ` +
227
237
  `agent alive; this mail is lost (ackTo=${envelope.ackTo})`);
228
238
  }
239
+ // Assigned rows only: the server's bury-guard checks what was MINE to
240
+ // handle. Ambient rows slide past the cursor unlisted — they were never
241
+ // this agent's to act on, and the owner's seen mark tracks them.
229
242
  const handledResourceIds = [
230
243
  ...new Set([
231
- ...envelope.deliveries.map((d) => d.resourceId),
232
- ...(envelope.questsAwaitingMe ?? []).map((q) => q.agreementId),
244
+ ...envelope.deliveries.filter(mine).map((d) => d.resourceId),
245
+ ...(envelope.requestsAwaitingMe ?? []).map((q) => q.agreementId),
233
246
  ]),
234
247
  ].filter((id) => typeof id === 'string' && id.length > 0);
235
248
  await inbox.ack(envelope.ackTo, { handledResourceIds });
@@ -245,7 +258,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
245
258
  messagesDelivered,
246
259
  resourceEvents,
247
260
  tasksDelivered,
248
- questsTriaged,
261
+ requestsTriaged,
249
262
  acked,
250
263
  truncated: envelope.deliveriesCapped,
251
264
  failures,
@@ -0,0 +1,5 @@
1
+ /**
2
+ * exact-match triage for marketplace requests.
3
+ * Empty match never hits (token-safe default). Comparison is exact after trim.
4
+ */
5
+ export declare function requestExactMatch(match: string, tags: ReadonlyArray<string | null | undefined>): boolean;
@@ -1,8 +1,8 @@
1
1
  /**
2
- * exact-match triage for marketplace quests.
2
+ * exact-match triage for marketplace requests.
3
3
  * Empty match never hits (token-safe default). Comparison is exact after trim.
4
4
  */
5
- export function questExactMatch(match, tags) {
5
+ export function requestExactMatch(match, tags) {
6
6
  const needle = typeof match === 'string' ? match.trim() : '';
7
7
  if (!needle)
8
8
  return false;
@@ -5,6 +5,14 @@ import { boundKeyFleetWarning } from '../shared/operatorKeyClaims.js';
5
5
  import { runtimeLog } from '../shared/runtimeLog.js';
6
6
  import { PollRegistry } from './polls/PollKind.js';
7
7
  import { PollRunner } from './polls/PollRunner.js';
8
+ /**
9
+ * How long a shutdown waits for in-flight turns after it has stopped taking new
10
+ * work. An agent turn is an LLM call, so the old 30s was under the length of the
11
+ * work it was meant to protect; the container's stop timeout is what has to be
12
+ * larger still, or the drain is cut short by SIGKILL regardless (ECS
13
+ * `stopTimeout`, 120s on the fleet task definitions).
14
+ */
15
+ const DRAIN_TIMEOUT_MS = Number(process.env.FLEET_DRAIN_TIMEOUT_MS) || 90_000;
8
16
  /**
9
17
  * Collect the poll kinds the agents themselves ship and start a runner for
10
18
  * whatever they declared. This lives in the launcher, not in a caller's
@@ -57,7 +65,7 @@ async function runSingle(config, { operatorKey, label = 'agent', port = process.
57
65
  runtimeLog.info(label, 'SIGTERM — shutting down');
58
66
  pollRunner?.stop();
59
67
  agent.disconnect();
60
- await agent.drain(30_000);
68
+ await agent.drain(DRAIN_TIMEOUT_MS);
61
69
  try {
62
70
  await onShutdown?.();
63
71
  }
@@ -122,10 +130,23 @@ async function runFleet(configs, { operatorKey, port = process.env.PORT || 8080,
122
130
  });
123
131
  process.on('SIGTERM', async () => {
124
132
  runtimeLog.info(label, 'SIGTERM — shutting down');
133
+ // Stop taking new work everywhere first, then wait for what is running.
134
+ //
135
+ // This used to disconnect and drain only `primary` — which `run.ts` never
136
+ // sets, so in the deployed fleet the drain was a no-op on null and the
137
+ // process exited straight through `disconnectAll`, killing every in-flight
138
+ // turn. A buyer whose request landed mid-rollout got a host that stopped
139
+ // mid-sentence and never reported.
140
+ //
141
+ // Draining before stopping intake is the other half of the same bug: the
142
+ // wait is exactly the window in which a host on its way out keeps accepting
143
+ // deliveries the incoming host is also being handed.
125
144
  pollRunner?.stop();
126
145
  primary?.disconnect?.();
127
- await primary?.drain?.(30_000);
128
- await pool.disconnectAll();
146
+ await Promise.all([
147
+ pool.shutdown(DRAIN_TIMEOUT_MS),
148
+ primary?.drain?.(DRAIN_TIMEOUT_MS) ?? Promise.resolve(),
149
+ ]);
129
150
  try {
130
151
  await onShutdown?.();
131
152
  }
@@ -19,7 +19,7 @@ export { PROTOCOL_TOOLS, agreementProposeTool, agreementSubcontractTool, agreeme
19
19
  export { dispatchProtocolOp } from './tier1/protocolRunner.js';
20
20
  export { DISCOVERY_TOOLS, agentSearchTool, agentGetTool } from './tier2/discoveryTools.js';
21
21
  export { MARKETPLACE_TOOLS, marketplaceViewTool } from './tier2/marketplaceTools.js';
22
- export { PAYMENT_TOOLS, paymentBalanceTool, paymentTransferTool, paymentWaitForApprovalTool, paymentHoldTool, paymentReleaseTool, paymentResolveWalletTool, paymentIssueGrantTool, paymentAttenuateGrantTool, paymentRevokeGrantTool, } from './tier2/paymentTools.js';
22
+ export { PAYMENT_TOOLS, paymentBalanceTool } from './tier2/paymentTools.js';
23
23
  export { CONNECTION_TOOLS, connectionProxyTool, connectionListGrantsTool, requestConnectionTool, } from './tier2/connectionTools.js';
24
24
  export { CONTEXT_TOOLS, contextReadTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
25
25
  export { GRANTS_TOOLS, listGrantsTool } from './tier2/grantsTools.js';
@@ -21,7 +21,7 @@ export { dispatchProtocolOp } from './tier1/protocolRunner.js';
21
21
  // ── Tier 2: capability bundles ──────────────────────────────────────────────
22
22
  export { DISCOVERY_TOOLS, agentSearchTool, agentGetTool } from './tier2/discoveryTools.js';
23
23
  export { MARKETPLACE_TOOLS, marketplaceViewTool } from './tier2/marketplaceTools.js';
24
- export { PAYMENT_TOOLS, paymentBalanceTool, paymentTransferTool, paymentWaitForApprovalTool, paymentHoldTool, paymentReleaseTool, paymentResolveWalletTool, paymentIssueGrantTool, paymentAttenuateGrantTool, paymentRevokeGrantTool, } from './tier2/paymentTools.js';
24
+ export { PAYMENT_TOOLS, paymentBalanceTool } from './tier2/paymentTools.js';
25
25
  export { CONNECTION_TOOLS, connectionProxyTool, connectionListGrantsTool, requestConnectionTool, } from './tier2/connectionTools.js';
26
26
  export { CONTEXT_TOOLS, contextReadTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
27
27
  // one grant lister across every rail (retires context_discover +
@@ -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[];
@@ -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[];