@ziggs-ai/agent-sdk 0.38.17 → 0.39.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
@@ -1,6 +1,12 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.39.0
4
+
5
+ - A manifest may declare `needs: [{ checkout: 'git' }]`. The host provides a throwaway box per wake (`checkout` option, a `CheckoutProvider`); the task names the repository as owner/name and may name a base branch; the brain gets `checkout_read`, `checkout_write`, `checkout_run` and `checkout_push`; the change is pushed to `agent/<task id>` for a pull request. Without a GitHub connection grant on the hire the task is refused by name, like a connection need. The box holds no credential: its git traffic is relayed by the host through the backend git door under the hire's grant. The box server ships as `dist/checkout/box/server.js` (`checkoutBoxServerPath()`).
6
+
7
+ - Every repository subprocess, including Git filters, runs with the same box confinement. File reads preserve the beginning, report actual byte counts and truncation, and build output is bounded while it arrives.
8
+ - Checkout providers may supply private checkpoint storage. Without it, later wakes resume the pushed task branch. Mutating tools and wake cleanup save staged, unstaged and untracked work; later wakes restore it without making or pushing a commit. Storage keys isolate agents, hire lanes, tasks, repositories and base branches. Save failures return warnings without replacing command results or failing completed wakes; task-read and checkout-open errors leave task status unchanged.
9
+ - Task intake preserves dotted repository names and reads the explicit base branch from either title or description. Relay shutdown cancels outstanding requests and cleanup stops the box even when checkpoint storage fails.
4
10
 
5
11
  ## 0.38.17
6
12
 
package/README.md CHANGED
@@ -120,6 +120,43 @@ capability bundles include `DISCOVERY_TOOLS`, `CONTEXT_TOOLS`, `CONNECTION_TOOLS
120
120
  Use `defineTool` for your own tools. Declared `needs` provide their access tools
121
121
  and enforce missing grants at task start.
122
122
 
123
+ A need can also be a checkout: `needs: [{ checkout: 'git' }]`. The host
124
+ provides a throwaway box per wake (the `checkout` option, a `CheckoutProvider`),
125
+ the task names the repository as owner/name and may name a base branch, and
126
+ the agent gets `checkout_read`, `checkout_write`, `checkout_run` and
127
+ `checkout_push`. The change is pushed to `agent/<task id>` for a pull request;
128
+ the default branch is never pushed. Without a GitHub connection grant the task
129
+ is refused by name. The box holds no credential: its git traffic is relayed by
130
+ the host through the backend under the grant. The box server ships as
131
+ `dist/checkout/box/server.js` (`checkoutBoxServerPath()`) and needs only Node
132
+ and git.
133
+
134
+ A provider may supply `checkpoints: CheckoutCheckpointStore` with `load(key)`
135
+ and atomic `save(key, bytes)` methods. Without storage, later wakes resume the
136
+ pushed task branch; tool results explicitly warn that unpushed work is temporary.
137
+ Checkpoints are private host storage, never a Git push. When configured, the SDK
138
+ saves after mutating tools and during wake cleanup. Keys include agent, hire lane,
139
+ task, repository and base branch. Archives are capped at 64 MiB compressed and
140
+ omit `node_modules` and `.npm`; reinstall dependencies after a restore.
141
+
142
+ A failed save preserves the tool's original result and adds `checkpointWarning`.
143
+ Further saves are disabled for that wake, so an oversized repository or storage
144
+ outage does not repeatedly delay every tool or fail an already completed wake.
145
+ Only the last successful checkpoint or pushed branch is recoverable. Loading a
146
+ configured store must succeed before work proceeds, to avoid overwriting saved
147
+ work. A task-read or checkout-open error leaves task status unchanged.
148
+
149
+ The reference Mac host retains local snapshots for seven days. The Fargate host
150
+ has no checkpoint storage by default and this rollout provisions no S3 snapshot
151
+ bucket. A production host can explicitly supply a store after establishing its
152
+ own retention policy and dedicated access controls.
153
+
154
+ All subprocesses, including Git and archive extraction, use `BOX_RUN_AS` or
155
+ `BOX_EXEC_PREFIX`. Providers must enforce outbound isolation independently of
156
+ proxy environment variables. The reference fleet uses subnets without an internet
157
+ route and a package proxy; the local host allows only the Git relay and that proxy.
158
+
159
+
123
160
  Configure an alternate backend before constructing an agent:
124
161
 
125
162
  ```ts
package/dist/Agent.d.ts CHANGED
@@ -30,6 +30,7 @@ import { ToolManager } from './tools/ToolManager.js';
30
30
  import { type ToolDefinition } from './tools/defineTool.js';
31
31
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
32
32
  import { type AgentNeed } from './tools/needs.js';
33
+ import type { CheckoutProvider } from './checkout/types.js';
33
34
  /**
34
35
  * Given a normalized event, return the sessionId for this agent. Return
35
36
  * `null` to skip processing. Lets each Agent define what "session" means —
@@ -89,6 +90,12 @@ export interface AgentOptions {
89
90
  * missing access named, before the tick spends anything.
90
91
  */
91
92
  needs?: AgentNeed[];
93
+ /**
94
+ * Who provides the checkout a `{ checkout: 'git' }` need asks for: the host.
95
+ * A fleet starts a container per wake, a lab a confined local process. Left
96
+ * unset, a task that needs a checkout is refused naming this host.
97
+ */
98
+ checkout?: CheckoutProvider;
92
99
  taskTools?: 'all' | 'none' | string[];
93
100
  /**
94
101
  * Brain selection — preferred over legacy `cognition`.
@@ -265,6 +272,8 @@ export declare class Agent {
265
272
  private readonly outboundMessages;
266
273
  private readonly confirmedPlanVersions;
267
274
  private grantProviderCache;
275
+ /** The checkout box each lane holds while its wake runs. */
276
+ private checkoutByLane;
268
277
  constructor(options?: AgentOptions);
269
278
  private _validate;
270
279
  private _setup;
@@ -383,6 +392,13 @@ export declare class Agent {
383
392
  */
384
393
  private canAskForAccess;
385
394
  private _processEvent;
395
+ /**
396
+ * The checkout a wake needs, opened before the brain runs. Null when this
397
+ * wake needs none; `'refused'` when the task was failed here by name.
398
+ */
399
+ private _openCheckoutForWake;
400
+ /** Destroy the lane's box, if it still holds one, and say what it cost. */
401
+ private _closeCheckoutForWake;
386
402
  /**
387
403
  * Re-run one recorded wake against THIS agent, and nothing else.
388
404
  *
package/dist/Agent.js CHANGED
@@ -33,7 +33,10 @@ import { marketplaceViewTool } from './platform/tools/tier2/marketplaceTools.js'
33
33
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
34
34
  import { AnthropicAdapter } from './adapters/AnthropicAdapter.js';
35
35
  import { ArtifactsClient, ConnectionsClient, ContextReadClient, TelemetryClient, sendChatMessage, presentSendResult, } from '@ziggs-ai/api-client';
36
- import { normalizeNeeds, unmetNeeds, unmetNeedsMessage, } from './tools/needs.js';
36
+ import { isCheckoutNeed, normalizeNeeds, unmetNeeds, unmetNeedsMessage, } from './tools/needs.js';
37
+ import { openCheckout, closeCheckout } from './checkout/open.js';
38
+ import { resolveBackendUrl } from './shared/backendUrl.js';
39
+ import { laneUnderAgreement } from '@ziggs-ai/api-client';
37
40
  import { TaskService } from './platform/tasks/TaskService.js';
38
41
  import { AgreementService } from './platform/agreements/AgreementService.js';
39
42
  import { INBOX_STOP_TIMEOUT_MS, runInboxLoop } from './platform/inboxLoop.js';
@@ -69,6 +72,11 @@ export function laneSessionIdForTask(task) {
69
72
  }
70
73
  return 'default';
71
74
  }
75
+ /**
76
+ * A wake that holds a checkout installs, builds and tests a repository, which
77
+ * takes longer than a conversation turn. Its watchdog is at least this.
78
+ */
79
+ const CHECKOUT_TICK_TIMEOUT_MS = Number(process.env.CLAUDE_SDK_CHECKOUT_TICK_TIMEOUT_MS ?? 40 * 60_000);
72
80
  function createToolManager(options) {
73
81
  const toolManager = new ToolManager();
74
82
  const userTools = options.tools || options.services?.tools || [];
@@ -217,6 +225,8 @@ export class Agent {
217
225
  outboundMessages = new SeenMessages();
218
226
  confirmedPlanVersions = new SeenMessages();
219
227
  grantProviderCache = null;
228
+ /** The checkout box each lane holds while its wake runs. */
229
+ checkoutByLane = new Map();
220
230
  constructor(options = {}) {
221
231
  const resolved = applyBrainToAgentOptions(options);
222
232
  this.options = {
@@ -238,6 +248,7 @@ export class Agent {
238
248
  // agents — the declaration existed and nothing carried it. Same for
239
249
  // `requestMatchTags` (always undefined at triage) and `telemetryIngest`.
240
250
  needs: resolved.needs,
251
+ checkout: resolved.checkout,
241
252
  taskTools: resolved.taskTools,
242
253
  states: resolved.states,
243
254
  initial: resolved.initial,
@@ -894,7 +905,7 @@ export class Agent {
894
905
  if (this.canAskForAccess() && !this.askedForAccessLanes.has(_laneKey)) {
895
906
  this.askedForAccessLanes.add(_laneKey);
896
907
  this.unmetTasksPendingAsk.set(_laneKey, mine.map((t) => String(t.taskId)));
897
- runtimeLog.info(this.options.name || 'agent', `no access for ${unmet.map((n) => n.connection).join('/')} on ${_laneKey} — letting this wake run once so it can ask, then failing whatever it leaves`);
908
+ runtimeLog.info(this.options.name || 'agent', `no access for ${unmet.map((n) => ('connection' in n ? n.connection : 'checkout')).join('/')} on ${_laneKey} — letting this wake run once so it can ask, then failing whatever it leaves`);
898
909
  return false;
899
910
  }
900
911
  for (const task of mine) {
@@ -1057,6 +1068,13 @@ export class Agent {
1057
1068
  frame = runnable.length === 1 ? runnable[0] : { type: 'batch', events: runnable };
1058
1069
  const memory = this.memoryForWake(session, frame);
1059
1070
  const wakeTaskId = workScopeFromWake(frame, session.sessionId).taskId;
1071
+ // A checkout agent gets its box before the brain runs: the task names the
1072
+ // repository, the hire's GitHub grant backs the clone, and a task that
1073
+ // names nothing or holds no grant is refused here by name, like the needs
1074
+ // gate above.
1075
+ const checkout = await this._openCheckoutForWake(laneKey, session.sessionId, frame, wakeTaskId);
1076
+ if (checkout === 'refused')
1077
+ return;
1060
1078
  // Recorded here rather than after the tick: this is the last point where the
1061
1079
  // input is still exactly what the brain will see, and a wake that goes on to
1062
1080
  // throw is the one most worth replaying.
@@ -1182,19 +1200,24 @@ export class Agent {
1182
1200
  };
1183
1201
  try {
1184
1202
  await checkpoint();
1185
- 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 }));
1203
+ 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 } : {}) }));
1186
1204
  const result = await Promise.race([
1187
1205
  tickWork,
1188
1206
  new Promise((_, reject) => {
1189
1207
  tickWatchdog = setTimeout(() => {
1190
1208
  tickAbort.abort(new Error('tick timeout'));
1191
1209
  reject(new Error('tick timeout'));
1192
- }, this.tickTimeoutMs);
1210
+ }, checkout ? Math.max(this.tickTimeoutMs, CHECKOUT_TICK_TIMEOUT_MS) : this.tickTimeoutMs);
1193
1211
  }),
1194
1212
  ]);
1195
1213
  session.ctx = result.ctx;
1196
1214
  session.state = result.state;
1197
1215
  this.sessions.set(laneKey, session);
1216
+ // The box dies with the wake, and what it cost rides the same cost line
1217
+ // as the wake's tokens.
1218
+ const boxCost = await this._closeCheckoutForWake(laneKey);
1219
+ if (boxCost && result.metrics)
1220
+ result.metrics = { ...result.metrics, box: boxCost };
1198
1221
  this.recordWakeOutcome(session, classifyWakeOutcome({
1199
1222
  skippedReason: result.skippedReason,
1200
1223
  llmCalls: result.metrics?.llmCalls ?? 0,
@@ -1328,6 +1351,53 @@ export class Agent {
1328
1351
  // work it was refused for sitting active.
1329
1352
  if (!heldTask)
1330
1353
  await this.settleUnmetAfterAsk(laneKey).catch(() => { });
1354
+ // A tick that threw still must not leave its box running.
1355
+ await this._closeCheckoutForWake(laneKey).catch(() => { });
1356
+ }
1357
+ }
1358
+ /**
1359
+ * The checkout a wake needs, opened before the brain runs. Null when this
1360
+ * wake needs none; `'refused'` when the task was failed here by name.
1361
+ */
1362
+ async _openCheckoutForWake(laneKey, sessionId, frame, taskId) {
1363
+ const needs = normalizeNeeds(this.options.needs);
1364
+ if (!needs.some(isCheckoutNeed) || !taskId)
1365
+ return null;
1366
+ const operatorKey = this.options.operatorKey;
1367
+ const agentId = this.options.agentId;
1368
+ if (!operatorKey || !agentId || !this.taskService)
1369
+ return null;
1370
+ const laneId = laneUnderAgreement(sessionId, workScopeFromWake(frame, sessionId).agreementId);
1371
+ const open = await openCheckout({
1372
+ provider: this.options.checkout,
1373
+ needs,
1374
+ taskId,
1375
+ getTask: (id) => this.taskService.getTask(id),
1376
+ listConnections: () => new ConnectionsClient(operatorKey, agentId, undefined, laneId).listForHolder(),
1377
+ door: { backendUrl: resolveBackendUrl(), operatorKey, agentId, laneId },
1378
+ failTask: async (id, reason) => {
1379
+ await this.taskService.updateState(id, 'failed', { summary: reason, status: 'failed' });
1380
+ },
1381
+ log: (message) => runtimeLog.info(this.options.name || 'agent', message),
1382
+ });
1383
+ if (open && open !== 'refused')
1384
+ this.checkoutByLane.set(laneKey, open);
1385
+ return open;
1386
+ }
1387
+ /** Destroy the lane's box, if it still holds one, and say what it cost. */
1388
+ async _closeCheckoutForWake(laneKey) {
1389
+ const open = this.checkoutByLane.get(laneKey);
1390
+ if (!open)
1391
+ return null;
1392
+ this.checkoutByLane.delete(laneKey);
1393
+ try {
1394
+ const cost = await closeCheckout(open);
1395
+ runtimeLog.info(this.options.name || 'agent', `checkout closed: ${cost.kind} box held ${cost.seconds}s`);
1396
+ return cost;
1397
+ }
1398
+ catch (err) {
1399
+ runtimeLog.warn(this.options.name || 'agent', `could not save/close the checkout box: ${err?.message ?? err}`);
1400
+ throw err;
1331
1401
  }
1332
1402
  }
1333
1403
  /**
@@ -84,6 +84,12 @@ export declare function isMissingSessionError(err: unknown): boolean;
84
84
  * the loop: instead of the FSM + `runTurn`, a wake drives a headless Claude
85
85
  * `query()` (see "host is the product; the brain is how it decides").
86
86
  */
87
+ /**
88
+ * The agreement every delivery in this wake is under, or undefined when none
89
+ * names one or they disagree. A batch across agreements names nothing, and the
90
+ * backend holds that wake to its room.
91
+ */
92
+ export declare function wakeAgreementOf(event: RawEvent | undefined): string | undefined;
87
93
  export declare class ClaudeBrain implements Brain {
88
94
  readonly kind: "claude";
89
95
  workflow: Workflow;
@@ -110,5 +116,5 @@ export declare class ClaudeBrain implements Brain {
110
116
  private toolContextByLane;
111
117
  private sdkMcpByLane;
112
118
  constructor(opts: ClaudeBrainOptions);
113
- tick({ event, ctx, state, identity, eff, memory, signal }: TickInput): Promise<TickOutput>;
119
+ tick({ event, ctx, state, identity, eff, memory, signal, checkout }: TickInput): Promise<TickOutput>;
114
120
  }
@@ -177,6 +177,23 @@ function extractMetrics(message) {
177
177
  * the loop: instead of the FSM + `runTurn`, a wake drives a headless Claude
178
178
  * `query()` (see "host is the product; the brain is how it decides").
179
179
  */
180
+ /**
181
+ * The agreement every delivery in this wake is under, or undefined when none
182
+ * names one or they disagree. A batch across agreements names nothing, and the
183
+ * backend holds that wake to its room.
184
+ */
185
+ export function wakeAgreementOf(event) {
186
+ if (!event)
187
+ return undefined;
188
+ const items = Array.isArray(event.events) && event.events.length > 0 ? event.events : [event];
189
+ const ids = new Set(items.map((item) => {
190
+ const id = item.resource?.agreementId;
191
+ return typeof id === 'string' && id ? id : null;
192
+ }));
193
+ if (ids.size !== 1)
194
+ return undefined;
195
+ return [...ids][0] ?? undefined;
196
+ }
180
197
  export class ClaudeBrain {
181
198
  kind = 'claude';
182
199
  workflow;
@@ -230,7 +247,7 @@ export class ClaudeBrain {
230
247
  this.actsOnHireActivation = opts.actsOnHireActivation === true;
231
248
  this.askWakeGate = opts.askWakeGate ?? null;
232
249
  }
233
- async tick({ event, ctx, state, identity, eff, memory, signal }) {
250
+ async tick({ event, ctx, state, identity, eff, memory, signal, checkout }) {
234
251
  if (!identity?.sessionId)
235
252
  throw new Error('ClaudeBrain.tick: identity.sessionId is required');
236
253
  if (!eff)
@@ -397,6 +414,9 @@ export class ClaudeBrain {
397
414
  eff,
398
415
  senderId: rawEvent?.senderId != null ? String(rawEvent.senderId) : undefined,
399
416
  senderType: rawEvent?.senderType != null ? String(rawEvent.senderType) : undefined,
417
+ // The agreement the delivery itself is under, so the backend scopes this
418
+ // wake to it. Read off the delivery, never a lane's remembered agreement.
419
+ wakeAgreementId: wakeAgreementOf(rawEvent),
400
420
  ///832: agreement this wake acts under → lets a delegated per-user
401
421
  // MCP call resolve the originating end-user (payer) rather than the
402
422
  // delegating agent (senderId). Resolved from every reliable wire source,
@@ -412,6 +432,9 @@ export class ClaudeBrain {
412
432
  // Hire-partitioned store. The host already scoped this wake;
413
433
  // Claude tools that persist notes must use this, not a process-wide bag.
414
434
  memory,
435
+ // The wake's checkout, when the host opened one: the checkout_* tools
436
+ // read it here and refuse on a wake that has none.
437
+ ...(checkout ? { checkout } : {}),
415
438
  };
416
439
  const lane = identity.laneKey;
417
440
  this.toolContextByLane.set(lane, toolContext);
@@ -27,6 +27,8 @@ export interface TickInput {
27
27
  * the agent's choice — `counterparty:<id>`, `agreement:<id>:notes`, etc.
28
28
  */
29
29
  memory?: MemoryStore;
30
+ /** The checkout the host opened for this wake, when the agent declares the need and the task names a repository. */
31
+ checkout?: import('../checkout/tools.js').CheckoutSession;
30
32
  /** Optional tap for FSM transition events. */
31
33
  transitionTap?: (event: TransitionEvent) => void;
32
34
  /**
@@ -63,6 +65,13 @@ export interface TickOutput {
63
65
  cacheRead?: number;
64
66
  cacheCreation?: number;
65
67
  };
68
+ /** The checkout box this wake held, when it held one: what kind, for how long, how big. */
69
+ box?: {
70
+ kind: string;
71
+ seconds: number;
72
+ vcpu?: number;
73
+ memoryMb?: number;
74
+ };
66
75
  };
67
76
  /** Tick skipped the model; host records a no-op naming why. */
68
77
  skippedReason?: string;
@@ -132,6 +132,7 @@ alwaysLoad = () => true) {
132
132
  onBehalfOfAgreementId: ctx.onBehalfOfAgreementId,
133
133
  runId: ctx.runId,
134
134
  writeKeys: ctx.writeKeys,
135
+ ...(ctx.checkout ? { checkout: ctx.checkout } : {}),
135
136
  });
136
137
  if (r.ok)
137
138
  return toCallToolResult(r.result);
@@ -0,0 +1 @@
1
+ export {};