@ziggs-ai/agent-sdk 0.40.0 → 0.41.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.
package/CHANGELOG.md CHANGED
@@ -32,6 +32,14 @@
32
32
 
33
33
  ## Unreleased
34
34
 
35
+ ## 0.41.0
36
+
37
+ - Claude-brain agents run work only under a task they hold. A custom tool, and the platform tools that write work onto a task, need a task assigned to the agent under an active work agreement, verified with the backend on every call; the task's agreement is the permission. The agent names the task in each wake: custom tools gain a `taskId` parameter the model fills and the handler never sees, later calls in the wake keep the task last named, and a task the agent opens for itself in that wake is the one it works under next. A task from an earlier wake is never a default. The host never opens a task and reads nothing off the waking message. Agreeing, managing, reading and talking stay open. Tool results carry the verified binding and operation records are scoped to it. `executionControl` lets other brains opt in or a host opt out; `execution: 'conversation'` keeps a custom tool open. Third-party API and MCP clients are unchanged.
38
+
39
+ ## 0.40.1
40
+
41
+ - `createSkillAgent` offers `artifact_record`, `artifact_list`, `artifact_find`, `artifact_share` and `artifact_attach`, so an agent built from instructions can keep its work as artifacts. The file tools that return a storage URL to upload to or fetch from are not offered: the agent has no web tool, and `artifact_record` already takes a file in one call. The set stays fixed in code; instruction text cannot add tools.
42
+
35
43
  ## 0.40.0
36
44
 
37
45
  - `Agent` names the body returned by `createAgent`; decision engines are `ZiggsBrain` and `ClaudeBrain`. See the README migration table.
package/README.md CHANGED
@@ -34,6 +34,64 @@ brain. It returns `{ agent, closeMcp, toolNames }`.
34
34
  Both implement `Brain`: a `kind` and `tick(TickInput): Promise<TickOutput>`.
35
35
  Both use the same agent identity, platform tools, memory and contracts.
36
36
 
37
+ ### Work runs under a task the agent holds
38
+
39
+ `Agent` enables `executionControl` by default for the Claude brain. Other
40
+ brains can enable it with `executionControl: true`; a host that provides its
41
+ own execution policy can set it to `false`. It is a code check in the shared
42
+ tool executor, so it covers nested calls and replayed wakes.
43
+
44
+ The rule: a tool that does work runs only against a task the agent holds, and
45
+ the task's agreement is the permission. Before such a tool runs, the host reads
46
+ the task and its agreement with the agent's own credentials and requires that
47
+ the task is assigned to this agent, that it is active and not paused, stopped,
48
+ held by a dependency or awaiting plan review, and that its agreement is an
49
+ active work agreement. Conversation links and store listings do not authorize
50
+ work. Nothing is read off the message that woke the agent, and the host never
51
+ opens a task on its own.
52
+
53
+ The agent names the task in each wake. Every custom tool gets a `taskId`
54
+ parameter the model fills; the handler never receives it. Later calls in the
55
+ same wake keep working under the task last named, and a task the agent opens
56
+ for itself with `task_create` in that wake is the task it works under next.
57
+ The lane keeps the task in its state so the model sees it next time, never as
58
+ a default: a task from an earlier wake has to be named again. Each call
59
+ verifies the task again, so a pause, a stop or a revocation stops the next
60
+ call.
61
+
62
+ Agreeing, managing, reading and talking stay open: proposing, countering,
63
+ responding, claiming, fulfilling and revoking agreements, opening or cancelling
64
+ tasks, listing, reading context, opening conversations, scheduling a wake and
65
+ sending messages need no task here. Their backend permissions still apply. The
66
+ platform tools that write work onto a task, `artifact_record`,
67
+ `task_set_result`, `task_update_plan_step` and `task_replace_plan`, need the
68
+ task they name. Updating the plan or setting the result of a paused or held
69
+ task is allowed; working on it is not.
70
+
71
+ Successful work tool results include an `execution` object with the verified
72
+ `agreementId` and `taskId`. Tool handlers receive `executionTaskId` and
73
+ `serviceAgreementId` in their context, and operation records carry both ids.
74
+ The agent still submits the actual result through `task_set_result`. Blocked
75
+ calls return a stable `errorCode` and an explanation naming the next step; the
76
+ Claude MCP bridge presents these as a structured error instead of invoking the
77
+ tool.
78
+
79
+ A tool author can keep a custom tool open when it only explains the service or
80
+ performs other conversation-only operations:
81
+
82
+ ```ts
83
+ defineTool('describe_service', {}, async () => 'I prepare project briefs.', {
84
+ execution: 'conversation',
85
+ });
86
+ ```
87
+
88
+ Use `execution: 'work'` to require a task for a tool otherwise treated as
89
+ open. These classifications are host configuration, not model arguments. The
90
+ controller governs tools this host runs; it cannot classify reply text,
91
+ translate agreement terms into permissions, or constrain tools a third-party
92
+ agent runs elsewhere. Backend grants remain authoritative for resources,
93
+ connections, delegation and spending.
94
+
37
95
  ```ts
38
96
  import { createAgent, claudeBrain } from '@ziggs-ai/agent-sdk';
39
97
 
package/dist/Agent.d.ts CHANGED
@@ -109,6 +109,8 @@ export interface AgentOptions {
109
109
  * Internal mapping: `fsm` → Ziggs brain, `claude-sdk` → Claude brain.
110
110
  */
111
111
  cognition?: CognitionKind;
112
+ /** Code-enforced work agreement/task binding. Defaults on for the Claude brain; other brains can opt in. */
113
+ executionControl?: boolean;
112
114
  /**
113
115
  * Spend a model turn on an activation notice for a hire an agent formed.
114
116
  * Default off — the host records a no-op and waits for the assigned task.
@@ -279,6 +281,19 @@ export declare class Agent {
279
281
  constructor(options?: AgentOptions);
280
282
  private _validate;
281
283
  private _setup;
284
+ private executionControlOn;
285
+ /**
286
+ * The door every work tool passes: the agent names a task it holds, the
287
+ * host verifies it with the backend, and the task's agreement is the
288
+ * permission. Reads use the agent's own credentials in this lane.
289
+ */
290
+ private executionController;
291
+ /**
292
+ * The lane's task and agreement after a wake, as the controller saw them.
293
+ * Kept so the model sees them in its context next time and can name the
294
+ * task again; the next wake never takes them as a default.
295
+ */
296
+ private carryExecutionState;
282
297
  /** Runtime control is a task fact, never model-interpreted message text. */
283
298
  private taskExecutionControl;
284
299
  private acknowledgeExecutionControl;
package/dist/Agent.js CHANGED
@@ -5,6 +5,7 @@ import { createDeclaredPollRunner } from './platform/polls/declaredPollRunner.js
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { AsyncLocalStorage } from 'node:async_hooks';
7
7
  import { contextScopesFromWake, WakeEffects, workScopeFromWake } from './runtime/wakeEffects.js';
8
+ import { ExecutionController, asWorkTool, executionControllers } from './runtime/executionController.js';
8
9
  export { workScopeFromWake } from './runtime/wakeEffects.js';
9
10
  import { ZiggsBrain } from './brain/ZiggsBrain.js';
10
11
  import { ClaudeBrain } from './brain/ClaudeBrain.js';
@@ -23,7 +24,7 @@ import { WAKE_TOOLS } from './platform/tools/tier1/wakeTools.js';
23
24
  import { createZiggsEffectHandler } from './platform/ZiggsEffectHandler.js';
24
25
  import { normalizeIncomingEvent } from './ingress/normalizeIncoming.js';
25
26
  import { PromptBuilder } from './runtime/PromptBuilder.js';
26
- import { removeTaskBook } from './runtime/taskBookkeeping.js';
27
+ import { ensureTaskBook, removeTaskBook } from './runtime/taskBookkeeping.js';
27
28
  import { hireIdsFromEvent, memorySectionForWake, partitionMemoryStore, uniqueHireIds, } from './memory/MemoryStore.js';
28
29
  import { memoryStoreFor } from './memory/ArtifactMemoryStore.js';
29
30
  import { ToolManager } from './tools/ToolManager.js';
@@ -32,7 +33,7 @@ import { PROTOCOL_TOOLS } from './platform/tools/tier1/protocolTools.js';
32
33
  import { marketplaceViewTool } from './platform/tools/tier2/marketplaceTools.js';
33
34
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
34
35
  import { AnthropicAdapter } from './adapters/AnthropicAdapter.js';
35
- import { ArtifactsClient, ConnectionsClient, ContextReadClient, TelemetryClient, sendChatMessage, presentSendResult, } from '@ziggs-ai/api-client';
36
+ import { ArtifactsClient, ConnectionsClient, ContextReadClient, TelemetryClient, sendChatMessage, presentSendResult, getAgreement, getTask, annotationForSdkTool, } from '@ziggs-ai/api-client';
36
37
  import { isCheckoutNeed, normalizeNeeds, unmetNeeds, unmetNeedsMessage, } from './tools/needs.js';
37
38
  import { openCheckout, closeCheckout } from './checkout/open.js';
38
39
  import { resolveBackendUrl } from './shared/backendUrl.js';
@@ -77,6 +78,19 @@ export function laneSessionIdForTask(task) {
77
78
  * takes longer than a conversation turn. Its watchdog is at least this.
78
79
  */
79
80
  const CHECKOUT_TICK_TIMEOUT_MS = Number(process.env.CLAUDE_SDK_CHECKOUT_TICK_TIMEOUT_MS ?? 40 * 60_000);
81
+ /**
82
+ * A platform tool keeps the platform classification wherever it was handed
83
+ * in (an artifact tool passed through `tools` is still an artifact tool).
84
+ * Anything else is a custom tool, and under execution control a custom tool
85
+ * does work: it gets a `taskId` the model fills (see asWorkTool).
86
+ */
87
+ const HAND_DEFINED_PLATFORM_TOOLS = new Set([...PROTOCOL_TOOLS, ...CHAT_TOOLS, ...CLAUDE_SDK_TOOLS, ...WAKE_TOOLS, marketplaceViewTool, contextReadTool, openTool, accessExplainTool]
88
+ .map((tool) => toolName(tool)));
89
+ function customToolAsWork(tool) {
90
+ const name = toolName(tool);
91
+ const platform = Boolean(name && (HAND_DEFINED_PLATFORM_TOOLS.has(name) || annotationForSdkTool(name) !== undefined));
92
+ return platform ? tool : asWorkTool(tool);
93
+ }
80
94
  function createToolManager(options) {
81
95
  const toolManager = new ToolManager();
82
96
  const userTools = options.tools || options.services?.tools || [];
@@ -112,7 +126,8 @@ function createToolManager(options) {
112
126
  : [];
113
127
  const platformTools = [...taskTools, ...CHAT_TOOLS, ...browseTools, ...claudeSdkTools, ...everydayContext, ...WAKE_TOOLS];
114
128
  warnOnShadowedPlatformTools(platformTools, ownTools, options.agentId);
115
- toolManager.registerAll([...platformTools, ...ownTools]);
129
+ const controlOn = options.executionControl ?? isClaudeCognition(options.cognition);
130
+ toolManager.registerAll([...platformTools, ...(controlOn ? ownTools.map(customToolAsWork) : ownTools)]);
116
131
  return toolManager;
117
132
  }
118
133
  /**
@@ -267,6 +282,7 @@ export class Agent {
267
282
  polls: resolved.polls,
268
283
  pollKinds: resolved.pollKinds,
269
284
  cognition: resolved.cognition ?? 'fsm',
285
+ executionControl: resolved.executionControl,
270
286
  actsOnHireActivation: resolved.actsOnHireActivation === true,
271
287
  wakeGate: resolved.wakeGate,
272
288
  claudeMaxTurns: resolved.claudeMaxTurns,
@@ -455,6 +471,43 @@ export class Agent {
455
471
  });
456
472
  });
457
473
  }
474
+ executionControlOn() {
475
+ return this.options.executionControl ?? isClaudeCognition(this.options.cognition);
476
+ }
477
+ /**
478
+ * The door every work tool passes: the agent names a task it holds, the
479
+ * host verifies it with the backend, and the task's agreement is the
480
+ * permission. Reads use the agent's own credentials in this lane.
481
+ */
482
+ executionController(identity, signal, onTaskBound) {
483
+ if (!this.executionControlOn())
484
+ return undefined;
485
+ const creds = { operatorKey: this.options.operatorKey, agentId: this.options.agentId, laneId: identity.sessionId };
486
+ return new ExecutionController({
487
+ agentId: identity.agentId, signal, onTaskBound,
488
+ readTask: id => getTask(id, creds),
489
+ readAgreement: id => getAgreement(id, creds),
490
+ });
491
+ }
492
+ /**
493
+ * The lane's task and agreement after a wake, as the controller saw them.
494
+ * Kept so the model sees them in its context next time and can name the
495
+ * task again; the next wake never takes them as a default.
496
+ */
497
+ carryExecutionState(ctx, controller) {
498
+ if (!ctx || !controller)
499
+ return;
500
+ const taskId = controller.currentTaskId;
501
+ if (taskId) {
502
+ ensureTaskBook(ctx, taskId, controller.currentAgreementId);
503
+ ctx.activeTaskId = taskId;
504
+ if (controller.currentAgreementId)
505
+ ctx.activeAgreementId = controller.currentAgreementId;
506
+ }
507
+ else if (controller.closedTask && ctx.activeTaskId === controller.closedTask) {
508
+ ctx.activeTaskId = null;
509
+ }
510
+ }
458
511
  /** Runtime control is a task fact, never model-interpreted message text. */
459
512
  async taskExecutionControl(taskId) {
460
513
  if (!this.taskService)
@@ -1105,6 +1158,7 @@ export class Agent {
1105
1158
  // event (duplicate sends, runaway per-wake input).
1106
1159
  const tickAbort = new AbortController();
1107
1160
  const marks = WakeEffects.forWake(frame, session.sessionId);
1161
+ const executionController = this.executionController(identity, tickAbort.signal, taskId => registerTaskRun(taskId));
1108
1162
  let heldTask;
1109
1163
  const pendingEffects = new Set();
1110
1164
  let savedSession;
@@ -1202,7 +1256,8 @@ export class Agent {
1202
1256
  };
1203
1257
  try {
1204
1258
  await checkpoint();
1205
- tickWork = this.wakeEffects.run(marks, () => this.brain.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff, memory, transitionTap: this.options.transitionTap, signal: tickAbort.signal, ...(checkout ? { checkout: checkout.session } : {}) }));
1259
+ const tick = () => this.wakeEffects.run(marks, () => this.brain.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff, memory, transitionTap: this.options.transitionTap, signal: tickAbort.signal, ...(checkout ? { checkout: checkout.session } : {}) }));
1260
+ tickWork = executionController ? executionControllers.run(executionController, tick) : tick();
1206
1261
  const result = await Promise.race([
1207
1262
  tickWork,
1208
1263
  new Promise((_, reject) => {
@@ -1213,6 +1268,7 @@ export class Agent {
1213
1268
  }),
1214
1269
  ]);
1215
1270
  session.ctx = result.ctx;
1271
+ this.carryExecutionState(session.ctx, executionController);
1216
1272
  session.state = result.state;
1217
1273
  this.sessions.set(laneKey, session);
1218
1274
  // The box dies with the wake, and what it cost rides the same cost line
@@ -1440,19 +1496,21 @@ export class Agent {
1440
1496
  // query outlives the promise that abandoned it and keeps spending.
1441
1497
  const abort = new AbortController();
1442
1498
  const marks = WakeEffects.forWake(record.event, identity.sessionId);
1499
+ const controller = this.executionController(identity, abort.signal);
1443
1500
  let watchdog;
1444
1501
  try {
1502
+ const tick = () => this.wakeEffects.run(marks, () => this.brain.tick({
1503
+ event: record.event,
1504
+ ctx: record.ctx,
1505
+ state: record.state,
1506
+ identity,
1507
+ eff: this.eff,
1508
+ memory,
1509
+ transitionTap: this.options.transitionTap,
1510
+ signal: abort.signal,
1511
+ }));
1445
1512
  const result = await Promise.race([
1446
- this.wakeEffects.run(marks, () => this.brain.tick({
1447
- event: record.event,
1448
- ctx: record.ctx,
1449
- state: record.state,
1450
- identity,
1451
- eff: this.eff,
1452
- memory,
1453
- transitionTap: this.options.transitionTap,
1454
- signal: abort.signal,
1455
- })),
1513
+ controller ? executionControllers.run(controller, tick) : tick(),
1456
1514
  new Promise((_, reject) => {
1457
1515
  watchdog = setTimeout(() => {
1458
1516
  abort.abort(new Error('tick timeout'));
@@ -1621,7 +1679,7 @@ export class Agent {
1621
1679
  * shutdown log) instead of EventQueue printing a stray warning.
1622
1680
  */
1623
1681
  drain(timeoutMs = 30_000) { return this.eventQueue.waitForIdle(timeoutMs); }
1624
- registerTool(tool) { return this.toolManager.register(tool); }
1682
+ registerTool(tool) { return this.toolManager.register(this.executionControlOn() ? customToolAsWork(tool) : tool); }
1625
1683
  isConnected() { return this.started && (!this.inboxLoopEnabled || (this.inboxLoop?.running ?? false)); }
1626
1684
  /**
1627
1685
  * Whether this agent is still reading its inbox, and when it last did. One
@@ -22,7 +22,7 @@ Your prose and final summary are private host notes, not messages to the people
22
22
 
23
23
  - The event in your prompt is your work order. Use \`context_snapshot\` when you need chat history, agreement details, or participants beyond what the event carries.
24
24
  - For factual answers, read relevant source artifacts before concluding information is missing. The snapshot's artifact list is a bounded inventory, not document contents; use \`context_read\` on the room's artifacts and follow pagination, or search artifacts when that tool is available. An empty direct \`grant_list\` is not evidence that room documents are unavailable. Report dated information with its date and distinguish it from a fresh confirmation. Check shared sources before repeating another agent's unsupported "no data" conclusion.
25
- - Work is a task under an agreement (the ticket). Post progress with \`task_update_plan_step\`.
25
+ - Work is a task under an agreement (the ticket). Post progress with \`task_update_plan_step\`. Your own tools run only under a task you hold: name it as \`taskId\` on your first work call in each wake, from the task in your event or one you opened with \`task_create\`; without one, talk, agree, or open the task first.
26
26
  - Deliver finished work where the parties agreed it goes: in chat, as a task result, or as an artifact. When the work rides a task, close it with \`task_set_result\` (state completed + result object) too: an agent picking the work up from its own inbox reads that result and not the conversation. Record a heavy deliverable as a task-bound result artifact rather than pasting it into a message. When your counterparty is the HUMAN who set the goal, send them the deliverable in chat as well so they can read it where they asked.
27
27
  - Closing a task closes the TASK, and only the task. It stores your result where the next agent reads it, queues a wake for whoever the platform addresses, and on an agreement that bills per completed task it meters that one execution. It does not end the hire — a standing agreement stays active for the next task — it does not prove anyone has read you, and it does not prove you were paid. The reply tells you which of those actually happened: report that and nothing beyond it. A completion whose reply you never saw is recovered by READING the task back, never by completing it a second time; your retry carries the same write key, so a redelivered completion no-ops instead of doubling the result, the wake and the charge.
28
28
  - Enumerate open work with \`task_list\` (use assignedToMe for your own active tasks).
@@ -134,8 +134,14 @@ alwaysLoad = () => true) {
134
134
  writeKeys: ctx.writeKeys,
135
135
  ...(ctx.checkout ? { checkout: ctx.checkout } : {}),
136
136
  });
137
- if (r.ok)
138
- return toCallToolResult(r.result);
137
+ if (r.ok) {
138
+ const result = toCallToolResult(r.result);
139
+ if (r.execution)
140
+ result.content.push({ type: 'text', text: JSON.stringify({ execution: r.execution }) });
141
+ return result;
142
+ }
143
+ if (r.errorCode)
144
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ code: r.errorCode, message: r.error }) }] };
139
145
  return toCallToolError(r.error ?? `tool "${name}" failed`);
140
146
  }
141
147
  catch (err) {
@@ -1,6 +1,7 @@
1
1
  import { OutboxBuffer } from './OutboxBuffer.js';
2
2
  import { runtimeLog } from '../shared/runtimeLog.js';
3
3
  import { toolFailureText } from '../brain/toolFailureText.js';
4
+ import { ExecutionRefusal, executionControllers } from '../runtime/executionController.js';
4
5
  import { artifactScopeForSession } from '@ziggs-ai/api-client';
5
6
  import { buildLlmIngestPayload, buildToolIngestPayload, isTelemetryIngestEnabled, sendTelemetry, } from './telemetryIngest.js';
6
7
  const CONTEXT_CACHE_MAX = 256;
@@ -84,7 +85,7 @@ function previewToolArgs(args) {
84
85
  * Chat timeline: `Message.tsx` parses `entryType: artifact`, `contentType: operation` with
85
86
  * `operation_*:{json}` where json includes `state` and `tool`.
86
87
  */
87
- async function emitToolOperationArtifact(client, sessionId, phase, payload, idempotencyKey, serviceAgreementId) {
88
+ async function emitToolOperationArtifact(client, sessionId, phase, payload, idempotencyKey, serviceAgreementId, taskId) {
88
89
  const prefix = phase === 'started' ? 'operation_started' : phase === 'completed' ? 'operation_completed' : 'operation_error';
89
90
  const text = `${prefix}:${JSON.stringify(payload)}`;
90
91
  try {
@@ -97,6 +98,7 @@ async function emitToolOperationArtifact(client, sessionId, phase, payload, idem
97
98
  contentType: 'operation',
98
99
  visibility: 'agent-private',
99
100
  serviceAgreementId,
101
+ ...(taskId ? { taskId } : {}),
100
102
  idempotencyKey,
101
103
  });
102
104
  }
@@ -122,17 +124,29 @@ async function emitToolOperationArtifact(client, sessionId, phase, payload, idem
122
124
  */
123
125
  export async function executeToolWithSideEffects(deps, eff, effect, roster) {
124
126
  const toolT0 = Date.now();
125
- const args = effect.args || {};
126
- const argsPreview = previewToolArgs(args);
127
+ let args = effect.args || {};
128
+ const controller = executionControllers.getStore();
129
+ let binding;
130
+ let argsPreview;
127
131
  // #6 — breadcrumbs are best-effort. Awaiting them bracketed every
128
132
  // tool call; a slow/wedged artifacts backend stalled the whole wake. Match
129
133
  // ClaudeBrain thought writes (fire-and-forget).
130
- void emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'started', {
131
- state: 'started',
132
- tool: effect.name,
133
- ...(argsPreview ? { args: argsPreview } : {}),
134
- }, effect.writeKeys?.artifactKey({ s: 'started', t: effect.name, a: argsPreview }), effect.serviceAgreementId);
135
134
  try {
135
+ if (controller) {
136
+ const checked = await controller.beforeTool(deps.toolManager.getTool(effect.name), args);
137
+ args = checked.args;
138
+ binding = checked.binding;
139
+ }
140
+ else if ('taskId' in args && deps.toolManager.getTool(effect.name)?.workTaskParam) {
141
+ // The host added this parameter for the controller; a handler never sees it.
142
+ args = Object.fromEntries(Object.entries(args).filter(([key]) => key !== 'taskId'));
143
+ }
144
+ argsPreview = previewToolArgs(args);
145
+ void emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'started', {
146
+ state: 'started',
147
+ tool: effect.name,
148
+ ...(argsPreview ? { args: argsPreview } : {}),
149
+ }, effect.writeKeys?.artifactKey({ s: 'started', t: effect.name, a: argsPreview }), binding?.agreementId ?? effect.serviceAgreementId, binding?.taskId);
136
150
  const lastUser = roster.users.find((u) => u.userId || u.id);
137
151
  // Accumulate token usage from any nested LLM call the tool makes (llmTool),
138
152
  // so it can be folded into the turn's authoritative token count.
@@ -165,8 +179,9 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
165
179
  agreements: roster.agreements ?? [],
166
180
  senderId: effect.senderId ?? lastUser?.userId ?? lastUser?.id,
167
181
  senderType: effect.senderType ?? (lastUser ? 'USER' : undefined),
168
- onBehalfOfAgreementId: effect.onBehalfOfAgreementId,
169
- serviceAgreementId: effect.serviceAgreementId,
182
+ onBehalfOfAgreementId: binding?.agreementId ?? effect.onBehalfOfAgreementId,
183
+ serviceAgreementId: binding?.agreementId ?? effect.serviceAgreementId,
184
+ ...(binding ? { executionTaskId: binding.taskId, wakeAgreementId: binding.agreementId } : {}),
170
185
  memory: effect.memory,
171
186
  writeKeys: effect.writeKeys,
172
187
  // The wake's checkout, when the host opened one.
@@ -176,6 +191,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
176
191
  reportUsage,
177
192
  turnScratch: {},
178
193
  });
194
+ controller?.observeTool(effect.name.replace(/^(functions|tools|function|tool)\./, ''), result);
179
195
  // One clip per tool call: this is a full JSON.stringify of the untruncated
180
196
  // result, and the artifact body and the idempotency key want the same string.
181
197
  const clipped = clipForOperationArtifact(result);
@@ -183,7 +199,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
183
199
  state: 'completed',
184
200
  tool: effect.name,
185
201
  result: clipped,
186
- }, effect.writeKeys?.artifactKey({ s: 'completed', t: effect.name, r: clipped }), effect.serviceAgreementId);
202
+ }, effect.writeKeys?.artifactKey({ s: 'completed', t: effect.name, r: clipped }), binding?.agreementId ?? effect.serviceAgreementId, binding?.taskId);
187
203
  sendTelemetry(deps.telemetryClient, buildToolIngestPayload({
188
204
  sessionId: effect.sessionId,
189
205
  tool: effect.name,
@@ -192,7 +208,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
192
208
  runId: effect.runId,
193
209
  stateId: effect.stateId,
194
210
  }), isTelemetryIngestEnabled(deps.telemetryIngest));
195
- return { ok: true, result, ...(nestedUsage ? { usage: nestedUsage } : {}) };
211
+ return { ok: true, result, ...(binding ? { execution: binding } : {}), ...(nestedUsage ? { usage: nestedUsage } : {}) };
196
212
  }
197
213
  catch (error) {
198
214
  const errMsg = toolFailureText(error);
@@ -201,7 +217,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
201
217
  state: 'failed',
202
218
  tool: effect.name,
203
219
  error: errMsg,
204
- }, effect.writeKeys?.artifactKey({ s: 'failed', t: effect.name, e: errMsg }), effect.serviceAgreementId);
220
+ }, effect.writeKeys?.artifactKey({ s: 'failed', t: effect.name, e: errMsg }), binding?.agreementId ?? effect.serviceAgreementId, binding?.taskId);
205
221
  sendTelemetry(deps.telemetryClient, buildToolIngestPayload({
206
222
  sessionId: effect.sessionId,
207
223
  tool: effect.name,
@@ -210,7 +226,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
210
226
  runId: effect.runId,
211
227
  stateId: effect.stateId,
212
228
  }), isTelemetryIngestEnabled(deps.telemetryIngest));
213
- return { ok: false, error: errMsg };
229
+ return { ok: false, error: errMsg, ...(error instanceof ExecutionRefusal ? { errorCode: error.code } : {}) };
214
230
  }
215
231
  }
216
232
  /**
@@ -0,0 +1,60 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { Task } from '@ziggs-ai/api-client';
3
+ import type { ToolDefinition } from '../tools/defineTool.js';
4
+ type Row = Record<string, unknown>;
5
+ export interface ExecutionBinding {
6
+ agreementId: string;
7
+ taskId: string;
8
+ }
9
+ interface ControllerOptions {
10
+ agentId: string;
11
+ readTask(id: string): Promise<Task>;
12
+ readAgreement(id: string): Promise<unknown>;
13
+ /** Called the first time a task is verified for work in this wake. */
14
+ onTaskBound?(taskId: string): void;
15
+ signal?: AbortSignal;
16
+ }
17
+ export declare const WORK_TASK_PARAM: {
18
+ type: string;
19
+ description: string;
20
+ };
21
+ /**
22
+ * A custom tool does work. It gets a `taskId` the model fills so it can name
23
+ * the task it works under; the host reads it and the handler never sees it.
24
+ * A tool that already takes `taskId` keeps its own, and one the author marked
25
+ * `conversation` stays open.
26
+ */
27
+ export declare function asWorkTool(tool: ToolDefinition): ToolDefinition;
28
+ export declare class ExecutionRefusal extends Error {
29
+ readonly code: string;
30
+ constructor(code: string, message: string);
31
+ }
32
+ /** Per-wake bookkeeping of which held task the agent works under. Never a replacement for backend grants. */
33
+ export declare class ExecutionController {
34
+ private readonly options;
35
+ private binding?;
36
+ /** A task the agent opened for itself in this wake, not yet verified for work. */
37
+ private named;
38
+ private closedTaskId?;
39
+ constructor(options: ControllerOptions);
40
+ /** The task this wake worked under: the last verified binding, or the task the agent opened for itself. Information for the next wake, never its default. */
41
+ get currentTaskId(): string | null;
42
+ get currentAgreementId(): string | null;
43
+ /** The bound task this wake closed with task_set_result, if any. */
44
+ get closedTask(): string | undefined;
45
+ private refuse;
46
+ private assertLive;
47
+ private isWork;
48
+ beforeTool(tool: ToolDefinition | null, args: Row): Promise<{
49
+ args: Row;
50
+ binding?: ExecutionBinding;
51
+ }>;
52
+ /**
53
+ * Read on every call: a previous successful call is not permission to keep
54
+ * acting after a pause, a stop or a revocation. Read failures fail closed.
55
+ */
56
+ private verify;
57
+ observeTool(name: string, value: unknown): void;
58
+ }
59
+ export declare const executionControllers: AsyncLocalStorage<ExecutionController>;
60
+ export {};
@@ -0,0 +1,165 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { toolName } from '../tools/defineTool.js';
3
+ import { isListing } from './agreementContext.js';
4
+ const row = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
5
+ const id = (v) => typeof v === 'string' && v.trim() ? v : undefined;
6
+ // The rule: a tool that does work runs only against a task the agent holds,
7
+ // and the task's agreement is the permission. Nothing here reads the wake or
8
+ // a snapshot, and nothing here opens a task: in each wake the agent names a
9
+ // task it was given, or opens one itself with task_create, which the backend
10
+ // prices and the buyer sees. A task from an earlier wake is never a default,
11
+ // so work cannot be filed under last week's task by omission. Agreeing,
12
+ // managing, reading and talking stay open; the backend authorizes each of
13
+ // those on its own.
14
+ /** Platform tools that write work onto a task. Every other platform tool is open. */
15
+ const PLATFORM_WORK = new Set(['artifact_record', 'task_set_result', 'task_update_plan_step', 'task_replace_plan']);
16
+ /** Managing a task you hold needs ownership and a live agreement, not a runnable task: failing a paused task is allowed. */
17
+ const TASK_MANAGEMENT = new Set(['task_set_result', 'task_update_plan_step', 'task_replace_plan']);
18
+ const AUTHORITY_ARGS = ['underAgreementId', 'mandateAgreementId', 'onBehalfOfAgreementId', 'parentAgreementId'];
19
+ const CLOSED = new Set(['completed', 'failed', 'cancelled']);
20
+ const WORKABLE = new Set(['active', 'in-progress']);
21
+ export const WORK_TASK_PARAM = {
22
+ type: 'string',
23
+ description: 'The task this work is for: one assigned to you under an active agreement. Name it on your first work call in each wake; later calls in the same wake keep it.',
24
+ };
25
+ /**
26
+ * A custom tool does work. It gets a `taskId` the model fills so it can name
27
+ * the task it works under; the host reads it and the handler never sees it.
28
+ * A tool that already takes `taskId` keeps its own, and one the author marked
29
+ * `conversation` stays open.
30
+ */
31
+ export function asWorkTool(tool) {
32
+ if (tool.execution === 'conversation')
33
+ return tool;
34
+ const fn = tool.schema.function;
35
+ const parameters = row(fn.parameters);
36
+ const properties = row(parameters.properties);
37
+ if ('taskId' in properties)
38
+ return { ...tool, execution: 'work' };
39
+ return {
40
+ ...tool,
41
+ execution: 'work',
42
+ workTaskParam: true,
43
+ schema: { ...tool.schema, function: { ...fn, parameters: { ...parameters, properties: { ...properties, taskId: WORK_TASK_PARAM } } } },
44
+ };
45
+ }
46
+ export class ExecutionRefusal extends Error {
47
+ code;
48
+ constructor(code, message) {
49
+ super(`${code}: ${message}`);
50
+ this.code = code;
51
+ }
52
+ }
53
+ /** Per-wake bookkeeping of which held task the agent works under. Never a replacement for backend grants. */
54
+ export class ExecutionController {
55
+ options;
56
+ binding;
57
+ /** A task the agent opened for itself in this wake, not yet verified for work. */
58
+ named = null;
59
+ closedTaskId;
60
+ constructor(options) {
61
+ this.options = options;
62
+ }
63
+ /** The task this wake worked under: the last verified binding, or the task the agent opened for itself. Information for the next wake, never its default. */
64
+ get currentTaskId() {
65
+ if (this.binding && this.binding.taskId !== this.closedTaskId)
66
+ return this.binding.taskId;
67
+ return this.named;
68
+ }
69
+ get currentAgreementId() {
70
+ return this.binding && this.binding.taskId === this.currentTaskId ? this.binding.agreementId : null;
71
+ }
72
+ /** The bound task this wake closed with task_set_result, if any. */
73
+ get closedTask() { return this.closedTaskId; }
74
+ refuse(code, message) { throw new ExecutionRefusal(code, message); }
75
+ assertLive() {
76
+ if (this.options.signal?.aborted)
77
+ this.refuse('EXECUTION_STOPPED', 'This wake has stopped. No new work can execute.');
78
+ }
79
+ isWork(tool, name) {
80
+ if (tool.execution === 'work')
81
+ return true;
82
+ if (tool.execution === 'conversation')
83
+ return false;
84
+ return PLATFORM_WORK.has(name);
85
+ }
86
+ async beforeTool(tool, args) {
87
+ this.assertLive();
88
+ const name = toolName(tool ?? undefined);
89
+ if (!tool || !name)
90
+ this.refuse('UNKNOWN_TOOL', 'The tool is not registered.');
91
+ if (!this.isWork(tool, name))
92
+ return { args };
93
+ const taskId = id(args.taskId) ?? this.binding?.taskId ?? this.named ?? undefined;
94
+ if (!taskId) {
95
+ this.refuse('TASK_REQUIRED', 'Work runs under a task you hold. Name a task assigned to you as taskId, or open one with task_create under your agreement with them and name it.');
96
+ }
97
+ const binding = await this.verify(taskId, TASK_MANAGEMENT.has(name));
98
+ for (const key of AUTHORITY_ARGS) {
99
+ if (args[key] != null && args[key] !== binding.agreementId) {
100
+ this.refuse('AGREEMENT_SCOPE_MISMATCH', `${key} must be the task's own agreement (${binding.agreementId}).`);
101
+ }
102
+ }
103
+ const handlerArgs = { ...args };
104
+ if (tool.workTaskParam)
105
+ delete handlerArgs.taskId;
106
+ else if ('taskId' in row(row(tool.schema.function.parameters).properties))
107
+ handlerArgs.taskId = binding.taskId;
108
+ return { args: handlerArgs, binding };
109
+ }
110
+ /**
111
+ * Read on every call: a previous successful call is not permission to keep
112
+ * acting after a pause, a stop or a revocation. Read failures fail closed.
113
+ */
114
+ async verify(taskId, managing) {
115
+ const task = await this.options.readTask(taskId);
116
+ if (task.taskId !== taskId || (task.executorIsYou !== true && task.assigneeId !== this.options.agentId)) {
117
+ this.refuse('TASK_NOT_HELD', `Task ${taskId} is not assigned to you. Work only under a task you hold.`);
118
+ }
119
+ const agreementId = id(task.agreementId);
120
+ const agreement = agreementId ? row(await this.options.readAgreement(agreementId)) : {};
121
+ if (!agreementId || agreement.agreementId !== agreementId || agreement.status !== 'active'
122
+ || agreement.engagementKind === 'link' || isListing(agreement)) {
123
+ this.refuse('AGREEMENT_INACTIVE', 'The task\'s agreement is not an active work agreement. Conversation links and listings do not authorize work.');
124
+ }
125
+ const state = String(task.state);
126
+ if (CLOSED.has(state) || task.control?.action === 'stop') {
127
+ this.refuse('TASK_NOT_RUNNABLE', `Task ${taskId} is closed or stopped. Take or open another task before working.`);
128
+ }
129
+ const review = row(task.planReview);
130
+ if (!managing && (!WORKABLE.has(state) || task.control?.action === 'pause' || task.heldByDeps || task.heldByParentReview
131
+ || task.closesBy === 'children' || review.executionAckStatus === 'pending' || review.midWorkAckStatus === 'pending'
132
+ || task.runIf?.outcome === 'held' || task.runIf?.outcome === 'skipped')) {
133
+ this.refuse('TASK_NOT_RUNNABLE', `Task ${taskId} is paused or awaiting dependencies or review. Resolve that before working on it; you can still update its plan or set its result.`);
134
+ }
135
+ this.assertLive();
136
+ const first = this.binding?.taskId !== taskId;
137
+ this.binding = { agreementId, taskId };
138
+ if (first)
139
+ this.options.onTaskBound?.(taskId);
140
+ return this.binding;
141
+ }
142
+ observeTool(name, value) {
143
+ const result = row(value);
144
+ if (result.ok === false || result.success === false || result.error)
145
+ return;
146
+ const task = row(result.task ?? result);
147
+ const taskId = id(task.taskId);
148
+ if (!taskId)
149
+ return;
150
+ // The agent opened a task for itself: that is the task it means to work
151
+ // under next. It is verified like any other before a tool runs.
152
+ if (name === 'task_create' && (task.assigneeId === this.options.agentId || task.executorIsYou === true))
153
+ this.named = taskId;
154
+ if (name === 'task_set_result' && CLOSED.has(String(task.state))) {
155
+ if (this.binding?.taskId === taskId)
156
+ this.closedTaskId = taskId;
157
+ if (this.named === taskId)
158
+ this.named = null;
159
+ }
160
+ }
161
+ }
162
+ // Covers nested tool calls as well as the brain's direct calls, while keeping
163
+ // concurrent wakes and agents isolated. No controller is mounted for external
164
+ // API/MCP clients; their authorization remains the backend's responsibility.
165
+ export const executionControllers = new AsyncLocalStorage();
@@ -1,5 +1,19 @@
1
1
  import { createAgent } from '../Agent.js';
2
+ import { ARTIFACT_TOOLS } from '../platform/tools/tier2/artifactTools.js';
3
+ import { toolName } from '../tools/defineTool.js';
2
4
  import { parseSkill, skillRolePrompt } from './parseSkill.js';
5
+ /** The artifact tools that finish inside one call, so instructions can keep
6
+ * their work as artifacts. The rest hand back a storage URL to upload to or
7
+ * fetch from, and this agent has no web tool to do that with.
8
+ */
9
+ const SKILL_ARTIFACT_TOOL_NAMES = new Set([
10
+ 'artifact_record',
11
+ 'artifact_list',
12
+ 'artifact_find',
13
+ 'artifact_share',
14
+ 'artifact_attach',
15
+ ]);
16
+ const SKILL_ARTIFACT_TOOLS = ARTIFACT_TOOLS.filter((tool) => SKILL_ARTIFACT_TOOL_NAMES.has(toolName(tool) ?? ''));
3
17
  /** The same instructions run on a host or on a person's machine. The trusted
4
18
  * caller supplies bytes; instruction text cannot add tools or runtime options.
5
19
  */
@@ -16,7 +30,7 @@ export function createSkillAgent(options) {
16
30
  const skill = parseSkill([{ path: 'SKILL.md', bytes: Buffer.from(text, 'utf8') }]);
17
31
  return skillRolePrompt(skill);
18
32
  },
19
- tools: [],
33
+ tools: [...SKILL_ARTIFACT_TOOLS],
20
34
  taskTools: 'all',
21
35
  actsOnHireActivation: true,
22
36
  respondsToProposals: true,
@@ -1,4 +1,8 @@
1
1
  export interface ToolDefinition {
2
+ /** Host-owned classification. Custom tools do work by default when execution control is enabled. */
3
+ execution?: 'conversation' | 'work';
4
+ /** The host added `taskId` to this tool's parameters so the model can name the task it works under; the handler never receives it. */
5
+ workTaskParam?: boolean;
2
6
  schema: {
3
7
  type: 'function';
4
8
  function: Record<string, unknown>;
@@ -19,6 +23,8 @@ export interface ToolDefinition {
19
23
  isGenericFallback?: boolean;
20
24
  }
21
25
  export interface DefineToolOptions {
26
+ /** Use conversation only for tools safe to run without a work task. This does not grant backend access. */
27
+ execution?: 'conversation' | 'work';
22
28
  description?: string;
23
29
  echoUserSummaryOnSuccess?: boolean;
24
30
  isAgreementCreation?: boolean;
@@ -74,6 +74,7 @@ export function defineTool(name, params, handler, options = {}) {
74
74
  return {
75
75
  schema: { type: 'function', function: fn },
76
76
  handler,
77
+ ...(options.execution ? { execution: options.execution } : {}),
77
78
  ...(options.echoUserSummaryOnSuccess ? { echoUserSummaryOnSuccess: true } : {}),
78
79
  ...(options.isAgreementCreation ? { isAgreementCreation: true } : {}),
79
80
  ...(options.isGenericFallback ? { isGenericFallback: true } : {}),
package/dist/types.d.ts CHANGED
@@ -389,6 +389,13 @@ export interface ReadContextOpts {
389
389
  */
390
390
  export interface ToolCallResult {
391
391
  ok: boolean;
392
+ /** Stable execution-controller refusal code, when the host blocked the call. */
393
+ errorCode?: string;
394
+ /** Host-verified work binding, supplied by the execution controller. */
395
+ execution?: {
396
+ agreementId: string;
397
+ taskId: string;
398
+ };
392
399
  result?: unknown;
393
400
  error?: string;
394
401
  events?: unknown[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
5
5
  "homepage": "https://ziggsai.com/docs",
6
6
  "bugs": {