@ziggs-ai/agent-sdk 0.1.11 → 0.1.12

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.
@@ -1,5 +1,6 @@
1
1
  import type { AgreementService } from '../../agreements/AgreementService.js';
2
2
  import type { TaskService } from '../../tasks/TaskService.js';
3
+ import type { ContextReadClient } from '@ziggs-ai/api-client';
3
4
  import {
4
5
  applyFleetCounterPricing,
5
6
  applyFleetPricingForProtocol,
@@ -15,6 +16,7 @@ function normalizeState(state: string): string {
15
16
  interface Services {
16
17
  agreementService: AgreementService;
17
18
  taskService: TaskService;
19
+ contextReadClient: ContextReadClient;
18
20
  }
19
21
 
20
22
  interface ProtocolPayload {
@@ -22,6 +24,7 @@ interface ProtocolPayload {
22
24
  agreementId?: string;
23
25
  description?: string;
24
26
  proposedTo?: string;
27
+ providerId?: string;
25
28
  executorId?: string;
26
29
  action?: 'approve' | 'reject';
27
30
  price?: number;
@@ -44,6 +47,39 @@ interface ProtocolPayload {
44
47
  [key: string]: unknown;
45
48
  }
46
49
 
50
+ // ZIG-793: the `plan` tool-schema is underspecified (`plan: {type:'object'}`,
51
+ // `planReviewTiming: 'string'`), so the LLM emits steps missing stepId/order and an
52
+ // out-of-enum planReviewTiming, which the backend DTO rejects. Normalize in-place here
53
+ // so every op (propose / subcontract / counter / task-spawn) sends a valid plan.
54
+ function normalizePlanInPlace(payload: ProtocolPayload): void {
55
+ const plan = payload.plan as { steps?: unknown } | undefined;
56
+ if (plan && Array.isArray(plan.steps)) {
57
+ plan.steps = plan.steps.map((s, i) => {
58
+ const step =
59
+ s && typeof s === 'object'
60
+ ? (s as Record<string, unknown>)
61
+ : { description: String(s) };
62
+ return {
63
+ ...step,
64
+ stepId:
65
+ typeof step.stepId === 'string' && step.stepId ? step.stepId : `step-${i + 1}`,
66
+ order: typeof step.order === 'number' ? step.order : i,
67
+ description:
68
+ typeof step.description === 'string'
69
+ ? step.description
70
+ : String(step.description ?? ''),
71
+ };
72
+ });
73
+ }
74
+ if (
75
+ payload.planReviewTiming !== undefined &&
76
+ payload.planReviewTiming !== 'with_proposal' &&
77
+ payload.planReviewTiming !== 'before_execution'
78
+ ) {
79
+ payload.planReviewTiming = 'with_proposal';
80
+ }
81
+ }
82
+
47
83
  export async function dispatchProtocolOp(
48
84
  services: Services,
49
85
  payload: ProtocolPayload,
@@ -52,6 +88,7 @@ export async function dispatchProtocolOp(
52
88
  actingAgentId: string | null = null,
53
89
  ): Promise<unknown> {
54
90
  if (!payload?.operation) return null;
91
+ normalizePlanInPlace(payload);
55
92
  const { operation } = payload;
56
93
  const { agreementService, taskService } = services;
57
94
  const actorId = actingAgentId?.trim() || null;
@@ -89,9 +126,66 @@ export async function dispatchProtocolOp(
89
126
  case 'agreement-revoke':
90
127
  if (!payload.agreementId) return null;
91
128
  return agreementService.revoke(payload.agreementId);
92
- case 'task-spawn':
129
+ case 'agreement-fulfill':
130
+ if (!payload.agreementId) return null;
131
+ return agreementService.fulfill(payload.agreementId);
132
+ case 'task-spawn': {
93
133
  if (!payload.agreementId || !payload.description) return null;
94
- return taskService.spawnUnderAgreement(payload.agreementId, {
134
+ let spawnAgreementId = payload.agreementId;
135
+ const ags = await agreementsInChat(services, chatId);
136
+ // b2 (ZIG-832): if the assignee is not a party on the cited agreement,
137
+ // redirect the spawn to the ACTIVE sub-agreement (same root) where the
138
+ // assignee IS the provider — the orchestrator often cites the top-level
139
+ // hire, which the backend rejects with "assignee not a party".
140
+ // ZIG-858: also redirect when the cited agreement is no longer active —
141
+ // a count-bound sub fulfills after its quota (default 1), and the LLM
142
+ // keeps citing that now-terminal sub for the next spawn even when a
143
+ // fresh active sub for the same executor exists.
144
+ if (payload.assigneeId) {
145
+ const cited = ags.find((a) => a.agreementId === payload.agreementId);
146
+ if (cited && (!agIsParty(cited, payload.assigneeId) || !agIsActiveStatus(cited))) {
147
+ const root = cited.rootAgreementId || payload.agreementId;
148
+ const sub = ags.find(
149
+ (a) => agIsActiveStatus(a) && agIsProvider(a, payload.assigneeId as string) &&
150
+ (a.rootAgreementId === root || a.parentAgreementId === payload.agreementId ||
151
+ a.parentAgreementId === root),
152
+ );
153
+ if (sub?.agreementId) spawnAgreementId = sub.agreementId;
154
+ }
155
+ }
156
+ // Safety guard (ZIG-832, B): a spawn against an agreement that is not active,
157
+ // or whose assignee is not a party on it, hard-400s at the backend AND makes
158
+ // the worker invent a "connection needed" message. That happens when the
159
+ // orchestrator spawns before its sub-agreement is subcontracted/approved.
160
+ // Skip GRACEFULLY instead — the sub-agreement's activation event will wake
161
+ // the orchestrator to spawn correctly on a later turn, no 400, no cascade.
162
+ const target = ags.find((a) => a.agreementId === spawnAgreementId);
163
+ // ZIG-858: a TERMINAL target is not the same as a not-yet-active one — no
164
+ // activation event will ever arrive, so the "wait for a later turn" skip
165
+ // below would stall the run. Tell the caller the actionable next step:
166
+ // subcontract a fresh sub for this executor, then spawn under it.
167
+ if (target && TERMINAL_STATUS.has(String(target.status))) {
168
+ return {
169
+ ok: false,
170
+ skipped: 'spawn-target-terminal',
171
+ reason: `agreement ${spawnAgreementId} is ${target.status} (terminal — its quota or term is exhausted). Subcontract a new sub-agreement to ${payload.assigneeId ?? 'the executor'}, then spawn this task under that new agreement.`,
172
+ agreementId: spawnAgreementId,
173
+ };
174
+ }
175
+ const notReady =
176
+ (target && !agIsActiveStatus(target)) ||
177
+ (!!payload.assigneeId && target && !agIsParty(target, payload.assigneeId));
178
+ if (notReady) {
179
+ return {
180
+ ok: false,
181
+ skipped: 'spawn-target-not-ready',
182
+ reason: target && !agIsActiveStatus(target)
183
+ ? `agreement ${spawnAgreementId} is ${target?.status ?? 'unknown'} (not active yet)`
184
+ : `assignee is not a party on ${spawnAgreementId} yet — subcontract to it first`,
185
+ agreementId: spawnAgreementId,
186
+ };
187
+ }
188
+ return taskService.spawnUnderAgreement(spawnAgreementId, {
95
189
  description: payload.description,
96
190
  parentTaskId: payload.parentTaskId,
97
191
  plan: payload.plan,
@@ -102,6 +196,7 @@ export async function dispatchProtocolOp(
102
196
  ? (payload.inputArtifactIds as string[])
103
197
  : undefined,
104
198
  });
199
+ }
105
200
  case 'task-update':
106
201
  if (!payload.taskId || !payload.status) return null;
107
202
  return taskService.updateState(payload.taskId, normalizeState(payload.status) as Parameters<typeof taskService.updateState>[1], payload.result);
@@ -109,7 +204,20 @@ export async function dispatchProtocolOp(
109
204
  if (!payload.taskId) throw new Error('task-update-plan-step requires `taskId`');
110
205
  if (!payload.stepId) throw new Error('task-update-plan-step requires `stepId`');
111
206
  if (!payload.status) throw new Error('task-update-plan-step requires `status`');
112
- return taskService.updatePlanStep(payload.taskId, payload.stepId, payload.status, payload.result);
207
+ // ZIG-793: a plan-step tick is cosmetic progress. Worker subtasks usually
208
+ // have NO plan (only the orchestrator's job task does), and the LLM may tick
209
+ // a stepId that isn't in the plan — the backend 400s on both. That must never
210
+ // hard-fail the tool → fail the task → wake-loop. Make it best-effort.
211
+ try {
212
+ return await taskService.updatePlanStep(
213
+ payload.taskId,
214
+ payload.stepId,
215
+ payload.status,
216
+ payload.result,
217
+ );
218
+ } catch {
219
+ return { ok: false, skipped: 'plan-step-not-updatable', stepId: payload.stepId };
220
+ }
113
221
  default:
114
222
  throw new Error(`Unknown protocol operation: ${operation}`);
115
223
  }
@@ -124,11 +232,64 @@ async function proposeAgreement(
124
232
  ): Promise<unknown> {
125
233
  if (!payload.description) throw new Error('agreement-propose requires `description`');
126
234
  if (!payload.proposedTo) throw new Error('agreement-propose requires `proposedTo` (userId or agentId). To spawn a task under an existing agreement, use `task_spawn` instead.');
235
+ const isBroadcast = payload.proposedTo === 'everyone' || payload.proposedTo === 'org';
236
+ if (!isBroadcast && !payload.providerId) {
237
+ throw new Error(
238
+ 'agreement-propose requires `providerId` — name who does the work: your own id (you are offering to work) or the proposedTo id (you are commissioning the recipient).',
239
+ );
240
+ }
241
+ if (isBroadcast && payload.providerId) {
242
+ throw new Error(
243
+ 'agreement-propose forbids `providerId` on a broadcast — the claiming agent fills the open side.',
244
+ );
245
+ }
127
246
  const priced = applyFleetPricingForProtocol('agreement-propose', payload, actingAgentId);
128
247
  const { operation: _op, ...fields } = priced;
129
248
  return services.agreementService.propose({ ...fields, chatId } as Parameters<typeof services.agreementService.propose>[0]);
130
249
  }
131
250
 
251
+ // ── ZIG-832: deterministic sub-agreement resolution ─────────────────────────
252
+ // The orchestrator delegates one task per worker, but the LLM inconsistently
253
+ // (a) re-subcontracts the same worker (duplicate sub-agreements) and (b)
254
+ // task_spawns a worker under the TOP-LEVEL hire — where the worker is not a
255
+ // party — instead of under that worker's sub-agreement, which the backend
256
+ // rejects with 400 "assignee is not a party". Both are made deterministic here
257
+ // using the chat's existing agreements.
258
+ const TERMINAL_STATUS = new Set(['fulfilled', 'cancelled', 'refunded', 'rejected']);
259
+ type AgLike = Record<string, unknown> & {
260
+ agreementId?: string; status?: string; rootAgreementId?: string;
261
+ parentAgreementId?: string; parties?: Record<string, unknown>;
262
+ };
263
+ async function agreementsInChat(services: Services, chatId: string | null): Promise<AgLike[]> {
264
+ if (!chatId) return [];
265
+ try {
266
+ const snap = await services.contextReadClient.snapshot(chatId, { maxMessages: 1 });
267
+ const ags = snap.agreements;
268
+ return Array.isArray(ags) ? (ags as AgLike[]) : [];
269
+ } catch {
270
+ return [];
271
+ }
272
+ }
273
+ function agIsProvider(a: AgLike, agentId: string): boolean {
274
+ const p = a.parties ?? {};
275
+ return p['provider'] === agentId || p['providerAgent'] === agentId || a['providerId'] === agentId;
276
+ }
277
+ function agIsParty(a: AgLike, agentId: string): boolean {
278
+ const p = a.parties ?? {};
279
+ return ['provider', 'providerAgent', 'payer', 'proposedTo', 'creator', 'creatorAgent']
280
+ .some((k) => p[k] === agentId);
281
+ }
282
+ function agActive(a: AgLike): boolean {
283
+ return !a.status || !TERMINAL_STATUS.has(String(a.status));
284
+ }
285
+ // Spawnable = actually ACTIVE. A freshly-subcontracted sub is `draft` until its
286
+ // counterparty auto-approves; spawning under a draft (or citing an agreement the
287
+ // assignee isn't a party on) 400s and cascades into a bogus worker "connection
288
+ // needed" message. We treat only 'active' as spawnable (ZIG-832).
289
+ function agIsActiveStatus(a: AgLike): boolean {
290
+ return String(a.status ?? '') === 'active';
291
+ }
292
+
132
293
  async function subcontractAgreement(
133
294
  services: Services,
134
295
  payload: ProtocolPayload,
@@ -137,8 +298,56 @@ async function subcontractAgreement(
137
298
  actingAgentId: string | null,
138
299
  ): Promise<unknown> {
139
300
  if (!payload.description || !payload.executorId) return null;
301
+ // b1 (ZIG-832): idempotent subcontract — if a non-terminal sub-agreement for
302
+ // this executor already exists under the same parent, reuse it instead of
303
+ // creating a duplicate (which bloats the chain and blocks the fulfill cascade).
304
+ if (chatId) {
305
+ const parent = payload.parentAgreementId;
306
+ const existing = (await agreementsInChat(services, chatId)).find(
307
+ (a) => agActive(a) && agIsProvider(a, payload.executorId as string) &&
308
+ (!parent || a.parentAgreementId === parent || a.rootAgreementId === parent),
309
+ );
310
+ if (existing?.agreementId) return existing;
311
+ }
140
312
  const priced = applyFleetPricingForProtocol('agreement-subcontract', payload, actingAgentId);
141
313
  const { operation: _op, ...fields } = priced;
314
+ // ZIG-832: a subcontract must carry a working close condition, or it hangs
315
+ // 'active' forever — and because the backend only auto-fulfills a parent hire
316
+ // once every child sub-agreement is terminal (the fulfill cascade), one
317
+ // never-closing child leaves the whole engagement open. Deciding lifecycle is
318
+ // left to the model, which under load (context bloat, competing wakes) emits
319
+ // incomplete terms — e.g. time-bound with no expiresAt and no maxExecutions —
320
+ // that never reach fulfillment. Make the common single-delegated-task case
321
+ // deterministic: if the caller gave neither a real quota nor a real deadline,
322
+ // treat it as one count-bound execution so completing that task closes the
323
+ // sub — which then cascades the parent closed. A genuinely durable subcontract
324
+ // still opts out by setting maxExecutions>1 or a time-bound expiresAt.
325
+ const f = fields as { lifecycle?: string; maxExecutions?: number | string; expiresAt?: string };
326
+ // ZIG-858: repair incomplete terms instead of clobbering them. The model
327
+ // often emits a real quota or deadline but gets the envelope wrong — a
328
+ // numeric-string maxExecutions, or a quota/deadline with the lifecycle field
329
+ // missing or mismatched. The old check treated all of those as "no close
330
+ // condition" and forced count-bound/1, silently discarding the stated intent
331
+ // (e.g. maxExecutions=2 → 1, so the sub fulfills one task early and the next
332
+ // spawn under it fails).
333
+ if (typeof f.maxExecutions === 'string' && f.maxExecutions.trim() !== '' && Number.isFinite(Number(f.maxExecutions))) {
334
+ f.maxExecutions = Number(f.maxExecutions);
335
+ }
336
+ const hasQuota = typeof f.maxExecutions === 'number' && f.maxExecutions > 0;
337
+ const hasDeadline = typeof f.expiresAt === 'string' && f.expiresAt.length > 0;
338
+ if (f.lifecycle === 'count-bound' && hasQuota) {
339
+ // coherent as stated
340
+ } else if (f.lifecycle === 'time-bound' && hasDeadline) {
341
+ // coherent as stated
342
+ } else if (hasQuota) {
343
+ f.lifecycle = 'count-bound'; // quota given, lifecycle missing/mismatched — keep the quota
344
+ } else if (hasDeadline) {
345
+ f.lifecycle = 'time-bound'; // deadline given, lifecycle missing/mismatched — keep the deadline
346
+ } else {
347
+ // no usable close condition at all — the deterministic single-task default
348
+ f.lifecycle = 'count-bound';
349
+ f.maxExecutions = 1;
350
+ }
142
351
  return services.agreementService.delegate({ ...fields, chatId } as Parameters<typeof services.agreementService.delegate>[0]);
143
352
  }
144
353
 
@@ -2,10 +2,12 @@ import { defineTool, type ToolDefinition } from '../../../tools/defineTool.js';
2
2
  import { dispatchProtocolOp } from './protocolRunner.js';
3
3
  import type { AgreementService } from '../../agreements/AgreementService.js';
4
4
  import type { TaskService } from '../../tasks/TaskService.js';
5
+ import type { ContextReadClient } from '@ziggs-ai/api-client';
5
6
 
6
7
  interface ToolContext extends Record<string, unknown> {
7
8
  taskService: TaskService;
8
9
  agreementService: AgreementService;
10
+ contextReadClient: ContextReadClient;
9
11
  chatId?: string | null;
10
12
  agents?: unknown[];
11
13
  /** Acting agent (ZIG-368 default pricing). */
@@ -17,8 +19,13 @@ function handlerFor(operation: string) {
17
19
  const c = ctx as ToolContext;
18
20
  if (!c.taskService) throw new Error('taskService missing from tool context (expected when running under Agent)');
19
21
  if (!c.agreementService) throw new Error('agreementService missing from tool context (expected when running under Agent)');
22
+ if (!c.contextReadClient) throw new Error('contextReadClient missing from tool context (expected when running under Agent)');
20
23
  const result = await dispatchProtocolOp(
21
- { taskService: c.taskService, agreementService: c.agreementService },
24
+ {
25
+ taskService: c.taskService,
26
+ agreementService: c.agreementService,
27
+ contextReadClient: c.contextReadClient,
28
+ },
22
29
  { operation, ...args },
23
30
  c.chatId ?? null,
24
31
  c.agents ?? [],
@@ -31,9 +38,9 @@ function handlerFor(operation: string) {
31
38
 
32
39
  export const agreementProposeTool: ToolDefinition = defineTool(
33
40
  'agreement_propose',
34
- { description: { type: 'string', required: true }, proposedTo: { type: 'string', required: true }, engagementKind: { type: 'string', enum: ['hire', 'service'], description: 'Contract type. Defaults to service when omitted.' }, parentAgreementId: 'string', parentTaskId: 'string', payerId: 'string', providerId: 'string', price: 'number', lifecycle: 'string', expiresAt: 'string', maxExecutions: 'number', agreementDescription: 'string', plan: { type: 'object' }, planReviewTiming: 'string', requireMidWorkPlanAck: 'boolean' },
41
+ { description: { type: 'string', required: true }, proposedTo: { type: 'string', required: true }, engagementKind: { type: 'string', enum: ['hire', 'service'], description: 'Contract type. Defaults to service when omitted.' }, parentAgreementId: 'string', parentTaskId: 'string', providerId: { type: 'string', description: 'Who does the work — required on direct proposals, forbidden on broadcasts (the claiming agent fills the open side). Pass your own id when YOU are offering to do the work, or the proposedTo id when you are commissioning the RECIPIENT. Naming any other third party requires that party to have a matching published offer. The payer is always derived as the non-providing side.' }, price: 'number', lifecycle: 'string', expiresAt: 'string', maxExecutions: 'number', agreementDescription: 'string', plan: { type: 'object' }, planReviewTiming: 'string', requireMidWorkPlanAck: 'boolean' },
35
42
  handlerFor('agreement-propose'),
36
- { description: 'Propose a contract to a user, agent, or as an open broadcast. For a broadcast set proposedTo="everyone" (fully public — any agent may claim) or proposedTo="org" (scoped to your org — only members of your org see and claim it). engagementKind defaults to service.', isAgreementCreation: true },
43
+ { description: 'Propose a contract to a user, agent, or as an open broadcast. Direction is stated, never guessed: providerId = your own id ("I work, you pay") or providerId = proposedTo ("you work, I pay"). Required on direct proposals; omit it only for a broadcast — proposedTo="everyone" (fully public — any agent may claim) or proposedTo="org" (scoped to your org). engagementKind defaults to service.', isAgreementCreation: true },
37
44
  );
38
45
 
39
46
  export const agreementSubcontractTool: ToolDefinition = defineTool(
@@ -78,6 +85,13 @@ export const agreementRevokeTool: ToolDefinition = defineTool(
78
85
  { 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.' },
79
86
  );
80
87
 
88
+ export const agreementFulfillTool: ToolDefinition = defineTool(
89
+ 'agreement_fulfill',
90
+ { agreementId: { type: 'string', required: true } },
91
+ handlerFor('agreement-fulfill'),
92
+ { description: 'Mark an agreement you provide as fulfilled (complete) once its work is delivered — closes the engagement so it no longer reads as in-progress. Use on your hire after the final deliverable is done and delivered to the human.' },
93
+ );
94
+
81
95
  export const taskUpdatePlanStepTool: ToolDefinition = defineTool(
82
96
  'task_update_plan_step',
83
97
  { taskId: { type: 'string', required: true }, stepId: { type: 'string', required: true }, status: { type: 'string', required: true }, result: 'string' },
@@ -95,5 +109,6 @@ export const taskSpawnTool: ToolDefinition = defineTool(
95
109
  export const PROTOCOL_TOOLS: ToolDefinition[] = [
96
110
  agreementProposeTool, agreementSubcontractTool,
97
111
  agreementRespondTool, agreementCounterProposalTool, agreementCheckProposalTool, agreementRevokeTool,
112
+ agreementFulfillTool,
98
113
  taskSpawnTool, taskUpdateTool, taskUpdatePlanStepTool,
99
114
  ];
package/src/types.ts CHANGED
@@ -167,6 +167,17 @@ export interface Ctx {
167
167
  // Last event/turn that caused this entry
168
168
  lastOutcome: Outcome;
169
169
 
170
+ /** Claude Agent SDK session id for resume across ticks (claude-sdk cognition only). */
171
+ claudeSessionId?: string;
172
+
173
+ /**
174
+ * Recent wake timestamps (epoch ms) for this lane (claude-sdk cognition
175
+ * only). Backstop circuit breaker: if a lane wakes faster than any real
176
+ * traffic could drive it, the brain skips the LLM instead of burning the
177
+ * fleet key on a wake storm.
178
+ */
179
+ recentWakes?: number[];
180
+
170
181
  // Server-provided context (history, agreements, agents, users) is fetched
171
182
  // on demand inside runTurn; not persisted on Ctx.
172
183
  }
@@ -244,7 +255,20 @@ export interface ThinkingState {
244
255
  allowNoWait?: boolean;
245
256
  }
246
257
 
247
- export type State = ParkedState | ThinkingState;
258
+ /**
259
+ * ZIG-764: fixed tool invoke with no LLM turn. Entered after transitions
260
+ * (e.g. task-assigned → executing); runs `tool` once and emits `produces`.
261
+ */
262
+ export interface MechanicalState {
263
+ kind: 'mechanical';
264
+ tool: string;
265
+ args?: Record<string, unknown>;
266
+ buildArgs?: (ctx: Ctx, outcome: Outcome) => Record<string, unknown>;
267
+ produces: Produces;
268
+ transitions: Transition[];
269
+ }
270
+
271
+ export type State = ParkedState | ThinkingState | MechanicalState;
248
272
 
249
273
  export interface Workflow {
250
274
  id: string;
@@ -300,16 +324,20 @@ export interface ContextSnapshot {
300
324
  timelineSummary?: string | null;
301
325
  latestSequence?: string | null;
302
326
  unavailable?: boolean;
327
+ /**
328
+ * The session's chat no longer resolves on the backend (messages read
329
+ * 404'd) — deleted or never visible to this agent. Message-triggered wakes
330
+ * check this and skip the LLM instead of reasoning over a fabricated empty
331
+ * history. Agreement-keyed lanes also set it (their sessionId is not a
332
+ * chat), so it only gates message wakes, never task lanes.
333
+ */
334
+ chatMissing?: boolean;
303
335
  /** Index signature so PromptBuilder's `ServerContext` can accept it directly. */
304
336
  [key: string]: unknown;
305
337
  }
306
338
 
307
339
  export interface ReadContextOpts {
308
340
  maxMessages?: number;
309
- maxCharacters?: number;
310
- maxTaskHistory?: number;
311
- summarize?: boolean;
312
- sinceSequence?: string;
313
341
  }
314
342
 
315
343
  /**
@@ -323,6 +351,12 @@ export interface ToolCallResult {
323
351
  result?: unknown;
324
352
  error?: string;
325
353
  events?: unknown[];
354
+ /**
355
+ * Summed token usage of any nested LLM call the tool made (e.g. llmTool).
356
+ * Threaded up so the turn's authoritative token count (machine.tokens ->
357
+ * result.metrics) includes tool-internal spend rather than leaking it (ZIG-813).
358
+ */
359
+ usage?: LlmResponse['usage'];
326
360
  }
327
361
 
328
362
  export interface RecordedEntry {
@@ -339,13 +373,7 @@ export type EffectKind =
339
373
  | 'stream-text'
340
374
  | 'tool-call'
341
375
  | 'send-message'
342
- | 'record-event'
343
- | 'get-scope'
344
- | 'list-messages'
345
- | 'list-artifacts'
346
- | 'list-task-history';
347
-
348
- export interface ScopeRef { kind: 'chat' | 'agreement' | 'task' | 'counterparty'; id: string }
376
+ | 'record-event';
349
377
 
350
378
  export type Effect =
351
379
  | { kind: 'read-context'; sessionId: string; opts?: ReadContextOpts }
@@ -376,17 +404,20 @@ export type Effect =
376
404
  memory?: MemoryStore;
377
405
  senderId?: string;
378
406
  senderType?: string;
407
+ /**
408
+ * Agreement this tool call is acting under (ZIG-831). For a delegated
409
+ * per-user MCP call, `senderId` is the delegating agent, not the end-user
410
+ * who owns the connection; this lets the gateway resolve the delegation
411
+ * root's user. Sourced from the waking task event's `result.agreementId`.
412
+ */
413
+ onBehalfOfAgreementId?: string;
379
414
  /** Per-turn UUID (ZIG-440). */
380
415
  runId?: string;
381
416
  /** FSM state name (ZIG-440). */
382
417
  stateId?: string;
383
418
  }
384
419
  | { kind: 'send-message'; sessionId: string; receiverId: string; text: string; messageId?: string }
385
- | { kind: 'record-event'; sessionId: string; entry: RecordedEntry }
386
- | { kind: 'get-scope'; via: ScopeRef }
387
- | { kind: 'list-messages'; chatId: string; after?: string; limit?: number }
388
- | { kind: 'list-artifacts'; chatId?: string; agreementId?: string; after?: string; limit?: number }
389
- | { kind: 'list-task-history'; taskId: string; after?: string; limit?: number };
420
+ | { kind: 'record-event'; sessionId: string; entry: RecordedEntry };
390
421
 
391
422
  /**
392
423
  * Typed result map. With this shape, `await eff({ kind: 'read-context', ... })`
@@ -396,14 +427,10 @@ export type Effect =
396
427
  export interface EffectResultMap {
397
428
  'read-context': ContextSnapshot;
398
429
  'llm-call': LlmResponse;
399
- 'stream-text': { text: string };
430
+ 'stream-text': { text: string; usage?: LlmResponse['usage'] };
400
431
  'tool-call': ToolCallResult;
401
432
  'send-message': { ok: true };
402
433
  'record-event': { ok: true };
403
- 'get-scope': unknown;
404
- 'list-messages': { messages: unknown[]; latestSequence: string | null };
405
- 'list-artifacts': { artifacts: unknown[]; latestSequence: string | null };
406
- 'list-task-history': { taskId: string; history: unknown[]; latestSequence: string | null };
407
434
  }
408
435
 
409
436
  export type EffectResult<K extends EffectKind> = EffectResultMap[K];