@dynamicagents/plugins 0.10.3 → 0.11.1

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/README.md CHANGED
@@ -136,8 +136,9 @@ npm run verify:exports
136
136
 
137
137
  ### Working against an unpublished core
138
138
 
139
- `@dynamicagents/core` is a **peer** dependency, so a plain `npm install` always resolves
140
- it from the registry — including over a local build you are testing against.
139
+ A plain `npm install` resolves `@dynamicagents/core` from whatever this branch's
140
+ `package.json` declares — `AGENTS.md` says which — and does it right over a local build
141
+ you are testing against.
141
142
 
142
143
  ```bash
143
144
  npm run link:local # npm pack + tarball install from ../core
@@ -150,7 +151,7 @@ repo's source for a contract change sitting uninstalled one directory away.
150
151
  `npm pack` + tarball, deliberately — not `npm link`, which symlinks the checkout and
151
152
  gives it its own copy of every peer. Two copies of `agents` in one bundle breaks the
152
153
  `Session` types and every `instanceof`, at runtime rather than at the type level.
153
- Nothing is written to `package.json`, so CI still builds against the real packages.
154
+ Nothing is written to `package.json`, so CI never builds against a local checkout.
154
155
 
155
156
  `test/arc-agi/recorded.spec.ts` drives the **real** ARC API and replays a committed
156
157
  cassette, so it needs no key either. Re-record it against the live API with:
@@ -14,6 +14,10 @@ import { type CookieJar, type FrameResponse, type GameInfo, type ScorecardSummar
14
14
  * exponential backoff. Exhausted retries and 5xx throw (transient — the
15
15
  * Workflow step retries and the runner resumes from its checkpoint); a 401
16
16
  * throws a tagged deterministic error (bad key — a terminal failure).
17
+ * - **Cancellation**: a client built with a `signal` stops at it — the `fetch` and
18
+ * the backoff between retries alike. `Retry-After` is honoured up to
19
+ * {@link MAX_RETRY_AFTER_MS} and no further: the header is the server's to set,
20
+ * and an uncapped one parks a tool for as long as the server likes.
17
21
  */
18
22
  export interface ArcClient {
19
23
  listGames(cookies: CookieJar): Promise<{
@@ -53,6 +57,12 @@ export interface ArcClient {
53
57
  cookies: CookieJar;
54
58
  }>;
55
59
  }
60
+ /**
61
+ * The longest a `Retry-After` is waited out. A minute covers the API's own
62
+ * per-minute rate window; past that the header is either hostile or describing an
63
+ * outage, and a retry loop is the wrong place to sit through either.
64
+ */
65
+ export declare const MAX_RETRY_AFTER_MS = 60000;
56
66
  /** Message prefix of the deterministic (non-retryable) auth error. */
57
67
  export declare const ARC_AUTH_ERROR = "arc-client: unauthorized";
58
68
  export interface ArcClientOptions {
@@ -61,5 +71,12 @@ export interface ArcClientOptions {
61
71
  sleep?: (ms: number) => Promise<void>;
62
72
  /** Max attempts per request before a 429/5xx becomes a thrown transient fault. */
63
73
  maxAttempts?: number;
74
+ /**
75
+ * Stops every request this client makes, and every wait between them. Taken at
76
+ * construction, so a client lives no longer than the signal it stops on: a tool
77
+ * builds one per call, from that call's signal — see
78
+ * {@link file://./tools.ts buildArcGameTools}.
79
+ */
80
+ signal?: AbortSignal;
64
81
  }
65
82
  export declare function makeArcClient(apiKey: string, options?: ArcClientOptions): ArcClient;
@@ -1,4 +1,11 @@
1
+ import { withAbort } from "@dynamicagents/core";
1
2
  import { ARC_BASE_URL } from "./types.js";
3
+ /**
4
+ * The longest a `Retry-After` is waited out. A minute covers the API's own
5
+ * per-minute rate window; past that the header is either hostile or describing an
6
+ * outage, and a retry loop is the wrong place to sit through either.
7
+ */
8
+ export const MAX_RETRY_AFTER_MS = 60_000;
2
9
  /** Message prefix of the deterministic (non-retryable) auth error. */
3
10
  export const ARC_AUTH_ERROR = "arc-client: unauthorized";
4
11
  const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -23,6 +30,7 @@ export function makeArcClient(apiKey, options = {}) {
23
30
  const doFetch = options.fetchFn ?? fetch;
24
31
  const sleep = options.sleep ?? realSleep;
25
32
  const maxAttempts = options.maxAttempts ?? 4;
33
+ const signal = options.signal;
26
34
  async function request(path, init, cookies) {
27
35
  const headers = {
28
36
  "X-API-Key": apiKey,
@@ -39,7 +47,8 @@ export function makeArcClient(apiKey, options = {}) {
39
47
  const res = await doFetch(`${ARC_BASE_URL}${path}`, {
40
48
  method: init.method,
41
49
  headers,
42
- body: init.body === undefined ? undefined : JSON.stringify(init.body)
50
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
51
+ ...(signal ? { signal } : {})
43
52
  });
44
53
  if (res.ok) {
45
54
  const nextCookies = mergeCookies(cookies, res);
@@ -56,9 +65,11 @@ export function makeArcClient(apiKey, options = {}) {
56
65
  }
57
66
  const retryAfter = Number(res.headers.get("retry-after"));
58
67
  const backoffMs = Number.isFinite(retryAfter) && retryAfter > 0
59
- ? retryAfter * 1000
68
+ ? Math.min(retryAfter * 1000, MAX_RETRY_AFTER_MS)
60
69
  : Math.min(1000 * 2 ** (attempt - 1), 8000);
61
- await sleep(backoffMs);
70
+ // The wait between attempts is the longest thing this loop does, so it is
71
+ // the part a cancel most needs to reach.
72
+ await withAbort(signal, sleep(backoffMs));
62
73
  }
63
74
  }
64
75
  return {
@@ -45,7 +45,6 @@ export function buildArcGameTools(apiKey, ctx) {
45
45
  // cannot do — `Env` is the ambient interface `wrangler types` generates into a
46
46
  // *consumer's* app and does not exist here. Config-at-instantiation is also
47
47
  // the only thing that works on Workers at all, where `env` has no module scope.
48
- const client = makeArcClient(apiKey);
49
48
  const runtime = runtimeAs(ctx.runtime);
50
49
  // All settled before the model runs — it cannot pick a different game, and it
51
50
  // cannot see, choose, or name a card or a play at all. The game is the type's
@@ -181,7 +180,19 @@ export function buildArcGameTools(apiKey, ctx) {
181
180
  .optional()
182
181
  .describe("brief reasoning for this move or sequence")
183
182
  }),
184
- execute: async ({ steps, note }) => {
183
+ execute: async ({ steps, note }, { abortSignal }) => {
184
+ // The call's own signal, not the execution's: it fires at this call's time
185
+ // limit as well as on a cancel, and past that limit core has stopped
186
+ // waiting — a batch still sending would spend moves on the card that the
187
+ // model is told never finished, and it may send them again. A caller
188
+ // with no loop hands `execute` no signal, and then a cancel is all there
189
+ // is to stop at.
190
+ const signal = abortSignal ?? ctx.signal;
191
+ const client = makeArcClient(apiKey, { signal });
192
+ const stoppedBy = () => signal?.reason?.name ===
193
+ "TimeoutError"
194
+ ? "this call reached its time limit"
195
+ : "this call was cancelled";
185
196
  // Acting immediately is right — the results below carry the board — but
186
197
  // this chunk's orientation goes out first, so the model is never acting
187
198
  // on a board it can no longer see.
@@ -249,6 +260,18 @@ export function buildArcGameTools(apiKey, ctx) {
249
260
  // exactly as a crash mid-action always was.
250
261
  session.pendingAction = { action, x, y };
251
262
  await save(session);
263
+ // The signal is checked here, with the intent already on disk, because this
264
+ // is the last moment before the request: a check ahead of the write would
265
+ // miss a cancel that lands during it. A request on an aborted signal is
266
+ // never sent, so the intent is taken back rather than left for the next
267
+ // chunk to warn about a move that never left. The batch ends the ordinary
268
+ // way, with every step already sent recorded.
269
+ if (signal?.aborted) {
270
+ session.pendingAction = null;
271
+ await save(session);
272
+ trace.push(remaining(steps.length, index, stoppedBy()));
273
+ break;
274
+ }
252
275
  const { frame, cookies } = await client.act({ action, gameId: session.gameId, guid: session.guid, x, y, note }, session.cookies);
253
276
  // Diff against the board as it was immediately BEFORE this step, so each
254
277
  // line attributes its change to the one action that caused it. That
@@ -52,7 +52,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
52
52
  });
53
53
  import { tool } from "ai";
54
54
  import { z } from "zod";
55
- import { definePlugin } from "@dynamicagents/core";
55
+ import { definePlugin, withAbort } from "@dynamicagents/core";
56
56
  import { getWorkspace, shellQuote } from "@cloudflare/computer";
57
57
  import { guardPath } from "./paths.js";
58
58
  import { cancelledNote, humanBytes, packBlocks, renderGrepMatches, renderResult } from "./render.js";
@@ -290,6 +290,31 @@ export function withShell(command, shell) {
290
290
  export function withShellTranscript(command, shell) {
291
291
  return shell ? `${wrapped(command, shell)} 2>&1` : command;
292
292
  }
293
+ /**
294
+ * Wait for a disposable resource without outliving `signal`.
295
+ *
296
+ * `withAbort` can abandon the wait but not the resource, and one that arrives
297
+ * after its caller has given up has nobody left to dispose it. So it is released
298
+ * on arrival instead — disposed by default, or handed to `release` where
299
+ * disposing alone would leave work running.
300
+ */
301
+ function acquire(signal, opening, release = (resource) => resource[Symbol.dispose]()) {
302
+ return withAbort(signal, opening, () => {
303
+ void opening.then(release).catch(() => { });
304
+ });
305
+ }
306
+ /**
307
+ * How an exec handle that arrived too late is released. Disposing only detaches
308
+ * this side, so the process is killed first.
309
+ */
310
+ async function killLate(handle) {
311
+ try {
312
+ await handle.kill("SIGTERM");
313
+ }
314
+ finally {
315
+ handle[Symbol.dispose]();
316
+ }
317
+ }
293
318
  export function buildComputerTools(workspace, config, advisories) {
294
319
  const cwd = config.cwd ?? DEFAULT_CWD;
295
320
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -311,7 +336,7 @@ export function buildComputerTools(workspace, config, advisories) {
311
336
  * it was dropping. The cost is one same-colo RPC on commands that previously
312
337
  * skipped it, against a class of silent data loss.
313
338
  */
314
- const awaitAdvisories = async (command) => {
339
+ const awaitAdvisories = async (command, signal) => {
315
340
  if (!advisories)
316
341
  return {};
317
342
  // Fails **open**, and that belongs here rather than at the call site: this
@@ -321,9 +346,14 @@ export function buildComputerTools(workspace, config, advisories) {
321
346
  // subagent.
322
347
  const read = async () => {
323
348
  try {
324
- return await advisories();
349
+ signal?.throwIfAborted();
350
+ return await withAbort(signal, advisories());
325
351
  }
326
- catch {
352
+ catch (err) {
353
+ // Open on a failed read, never on a cancel: failing open here would go on
354
+ // to run the command the caller just gave up on.
355
+ if (signal?.aborted)
356
+ throw err;
327
357
  return [];
328
358
  }
329
359
  };
@@ -338,7 +368,9 @@ export function buildComputerTools(workspace, config, advisories) {
338
368
  // the outcome.
339
369
  while (execGate(current, command).block && Date.now() < deadline) {
340
370
  const wait = Math.min(INSTALL_POLL_MS, deadline - Date.now());
341
- await new Promise((resolve) => setTimeout(resolve, wait));
371
+ // Throws on a cancel rather than returning the verdict so far: a gate that
372
+ // outlived its caller would otherwise go on to run the command it was holding.
373
+ await withAbort(signal, new Promise((resolve) => setTimeout(resolve, wait)));
342
374
  current = await read();
343
375
  }
344
376
  return execGate(current, command);
@@ -444,7 +476,7 @@ export function buildComputerTools(workspace, config, advisories) {
444
476
  .optional()
445
477
  .describe(`Working directory (default: ${cwd})`)
446
478
  }),
447
- execute: async ({ command, cwd: overrideCwd }) => {
479
+ execute: async ({ command, cwd: overrideCwd }, { abortSignal }) => {
448
480
  /**
449
481
  * One line per command, and it is the only view of where a task's wall
450
482
  * clock actually goes.
@@ -462,13 +494,50 @@ export function buildComputerTools(workspace, config, advisories) {
462
494
  * indistinguishable from the outside and want opposite fixes.
463
495
  */
464
496
  const startedAtMs = Date.now();
497
+ /**
498
+ * What the model is told when this call stops before the command
499
+ * finishes. Said plainly, because the likeliest next move after a bare
500
+ * error is the same command again — and whether that is right depends on
501
+ * where the call stopped. A command that outran the limit once will again;
502
+ * one that never started because the workspace did not answer may not.
503
+ *
504
+ * `sb_exec` reads its call's signal rather than leaving the wait to core,
505
+ * and this is why: core's abandonment can say only that the command may
506
+ * still be running. Core's `TOOL_CALL_GRACE_MS` is the window this answer
507
+ * has to arrive in, and it covers sending the kill below.
508
+ */
509
+ const stopped = (gateMs, started) => {
510
+ const timedOut = abortSignal?.reason?.name ===
511
+ "TimeoutError";
512
+ console.info("[computer] sb_exec stopped", {
513
+ command,
514
+ gateMs,
515
+ durationMs: Date.now() - startedAtMs - gateMs,
516
+ started,
517
+ reason: timedOut ? "time limit" : "cancelled"
518
+ });
519
+ if (!timedOut)
520
+ return "the command was stopped because this call was cancelled. Anything it changed before then is still changed.";
521
+ return started
522
+ ? "the command was stopped: it ran past this call's time limit. Anything it changed before then is still changed. Try something narrower."
523
+ : "the command did not run: this call reached its time limit while the workspace was still getting ready, so nothing was changed. Try again; if it happens again, the workspace is not responding.";
524
+ };
465
525
  // Only `sb_exec` waits on an install. The file tools read and write
466
526
  // source, which is in the workspace and unaffected by an install in
467
527
  // flight — blocking them would stop the subagent doing the reading it
468
528
  // could usefully do while it waits. They do consult `writeGate`, which
469
529
  // is a different question: not "is the tree ready" but "does a write
470
530
  // survive at all".
471
- const gate = await awaitAdvisories(command);
531
+ let gate;
532
+ try {
533
+ gate = await awaitAdvisories(command, abortSignal);
534
+ }
535
+ catch (err) {
536
+ // Only a cancel gets out of the gate; a failed advisory read opens it.
537
+ if (abortSignal?.aborted)
538
+ return stopped(Date.now() - startedAtMs, false);
539
+ throw err;
540
+ }
472
541
  const gateMsWaited = Date.now() - startedAtMs;
473
542
  if (gate.block) {
474
543
  console.info("[computer] sb_exec blocked by a workspace advisory", {
@@ -481,10 +550,15 @@ export function buildComputerTools(workspace, config, advisories) {
481
550
  // is context for the output, not a substitute for it — which is why it
482
551
  // is carried through the catch as well.
483
552
  const note = (body) => gate.warn ? `${gate.warn}\n\n${body}` : body;
553
+ // Set once the exec is sent. See `stopped`.
554
+ let started = false;
484
555
  try {
485
556
  const env_2 = { stack: [], error: void 0, hasError: false };
486
557
  try {
487
- const ws = __addDisposableResource(env_2, await workspace(), false);
558
+ // A workspace that stops answering would hold the call before any of the
559
+ // cancellation below is reached.
560
+ abortSignal?.throwIfAborted();
561
+ const ws = __addDisposableResource(env_2, await acquire(abortSignal, workspace()), false);
488
562
  // Read once per command. This is the *host's* thunk, so calling it
489
563
  // twice in the construction of one command's options is two chances to
490
564
  // disagree — the check and the value would come from different reads.
@@ -497,8 +571,17 @@ export function buildComputerTools(workspace, config, advisories) {
497
571
  };
498
572
  // Transcript, not two streams: this result goes to a model, which
499
573
  // reads it as a terminal session rather than parsing it.
500
- const handle = __addDisposableResource(env_2, await ws.runtime.exec(withShellTranscript(command, config.shell), options), false);
501
- const result = await handle.result();
574
+ // Not started at all on a signal that has already fired: a process that
575
+ // is killed on the next line still ran long enough to change something.
576
+ abortSignal?.throwIfAborted();
577
+ // From here a stop cannot promise nothing ran: the exec may reach the
578
+ // container before its handle reaches this side.
579
+ started = true;
580
+ const handle = __addDisposableResource(env_2, await acquire(abortSignal, ws.runtime.exec(withShellTranscript(command, config.shell), options), killLate), false);
581
+ // The runtime takes no signal, so stopping the wait and stopping the
582
+ // process are two acts. Disposing the handle is neither — it releases
583
+ // this side's attachment and leaves the command running.
584
+ const result = await withAbort(abortSignal, handle.result(), () => handle.kill("SIGTERM"));
502
585
  console.info("[computer] sb_exec", {
503
586
  command,
504
587
  exitCode: result.exitCode,
@@ -520,6 +603,8 @@ export function buildComputerTools(workspace, config, advisories) {
520
603
  }
521
604
  }
522
605
  catch (err) {
606
+ if (abortSignal?.aborted)
607
+ return note(stopped(gateMsWaited, started));
523
608
  const lost = execLostNote(err);
524
609
  console.warn("[computer] sb_exec failed", {
525
610
  command,
@@ -119,10 +119,7 @@ export function recall(config) {
119
119
  });
120
120
  const { embeddings } = await embedMany({
121
121
  model: provider.embedding(embeddingModelId),
122
- values: texts,
123
- // Failures surface to the caller; archival is best-effort and the SDK's
124
- // backoff would only add latency in front of a compaction.
125
- maxRetries: 0
122
+ values: texts
126
123
  });
127
124
  return embeddings;
128
125
  });
@@ -34,7 +34,7 @@ export interface GitAnswer {
34
34
  stderr: string;
35
35
  unreachable?: true;
36
36
  }
37
- /** The two runners `refreshCheckout` borrows from `buildRepoTools`. */
37
+ /** The two runners `refreshCheckout` borrows from `repoSurface`. */
38
38
  export interface GitRunners {
39
39
  plain: (args: string, cwd: string, vars?: Record<string, string>) => Promise<GitAnswer>;
40
40
  /**
@@ -1,5 +1,5 @@
1
1
  import type { ToolSet } from "ai";
2
- import type { AgentPlugin } from "@dynamicagents/core";
2
+ import type { AgentPlugin, MainAgentToolApproval } from "@dynamicagents/core";
3
3
  /**
4
4
  * The URL parsing, re-exported from the one entry point.
5
5
  *
@@ -311,4 +311,17 @@ export declare function truncateOutput(text: string, max: number): string;
311
311
  export declare function buildRepoTools(config: RepoConfig,
312
312
  /** Forwarded to every `exec`; see {@link RepoExec}'s `runtime` option. */
313
313
  runtime?: unknown): ToolSet;
314
+ /**
315
+ * The repo tools a person approves before they run: opening a pull request, and
316
+ * nothing else. Pushing a work branch and commenting run without asking.
317
+ *
318
+ * The reason is what the person reads beside Approve and Reject, so it says what
319
+ * the call will do in their terms: which repository, from which branch into
320
+ * which, and under what title. The repository is the checkout's origin, read
321
+ * through the same lookup the tool runs, so a directory named after one
322
+ * repository cannot present a pull request on another. The input has already
323
+ * passed the tool's own schema. What a rule is, and whose calls it covers, is
324
+ * core's to say — see `AgentPlugin.mainAgentToolApproval`.
325
+ */
326
+ export declare function repoToolApproval(config: RepoConfig): MainAgentToolApproval;
314
327
  export declare function repo(config: RepoConfig): AgentPlugin;
@@ -157,7 +157,7 @@ function unreachableNote(err) {
157
157
  * `repo_push` race: the commit fails, the push succeeds against the previous
158
158
  * state, and the round reports work that never landed.
159
159
  *
160
- * Held here rather than in {@link buildRepoTools}'s closure, because that closure
160
+ * Held here rather than in {@link repoSurface}'s closure, because that closure
161
161
  * is too short-lived to be the boundary. Core rebuilds `mainAgentTools` every
162
162
  * turn and gives each subagent execution its own tool family, so a per-call queue
163
163
  * leaves a subagent racing its parent over one checkout — the same collision, one
@@ -196,6 +196,14 @@ const gitQueues = new WeakMap();
196
196
  export function buildRepoTools(config,
197
197
  /** Forwarded to every `exec`; see {@link RepoExec}'s `runtime` option. */
198
198
  runtime) {
199
+ return repoSurface(config, runtime).tools;
200
+ }
201
+ /**
202
+ * The repo tools, and the approval rule for the one a person approves — one
203
+ * closure, so the rule reads the checkout through the same `origin` the tool it
204
+ * gates runs against. See {@link repoToolApproval}.
205
+ */
206
+ function repoSurface(config, runtime) {
199
207
  const workdir = config.workdir ?? DEFAULT_WORKDIR;
200
208
  const apiBase = config.apiBase ?? DEFAULT_API_BASE;
201
209
  const author = config.author ?? DEFAULT_AUTHOR;
@@ -502,7 +510,7 @@ runtime) {
502
510
  // `.git/config`.
503
511
  return { remote: { host: location.host, url: location.url } };
504
512
  };
505
- return {
513
+ const tools = {
506
514
  repo_clone: tool({
507
515
  description: "Clone a git repository into the workspace. Returns the checkout path and the branch you landed on. If the repository is already checked out from an earlier task, it is fetched and reset to the remote instead — unless it has uncommitted changes, which are left alone for you to inspect.",
508
516
  inputSchema: z.object({
@@ -1044,11 +1052,63 @@ runtime) {
1044
1052
  }
1045
1053
  })
1046
1054
  };
1055
+ /**
1056
+ * Where a pull request is opened, as the person approving it reads it: the
1057
+ * host, owner and repository of the checkout's origin — what the tool resolves
1058
+ * when it runs. Never the directory's name, which says nothing about where
1059
+ * `.git/config` points. A checkout with no readable origin on an allowed host is
1060
+ * named by its path, and the tool refuses it when it runs.
1061
+ */
1062
+ const destination = async (dir) => {
1063
+ const { remote } = await origin(dir);
1064
+ const parsed = remote ? parseRepo(remote.url, allowedHosts) : undefined;
1065
+ return remote && parsed
1066
+ ? `${remote.host}/${parsed.owner}/${parsed.repo}`
1067
+ : `the checkout at ${approvalField(dir)}, whose origin could not be read`;
1068
+ };
1069
+ const approval = {
1070
+ repo_open_pr: async (input) => {
1071
+ const { dir, head, base, title } = input;
1072
+ return {
1073
+ type: "user-approval",
1074
+ reason: `Open a pull request on ${await destination(dir)} from \`${approvalField(head)}\` into \`${approvalField(base)}\`: ${approvalField(title)}`
1075
+ };
1076
+ }
1077
+ };
1078
+ return { tools, approval };
1079
+ }
1080
+ /**
1081
+ * The longest branch, title or path a person is shown in full before approving.
1082
+ * Every model-supplied field in a reason is cut to it, so no one input can bury
1083
+ * the rest of what they are deciding.
1084
+ */
1085
+ const APPROVAL_FIELD_CHARS = 200;
1086
+ /** One model-supplied field of an approval reason, cut to {@link APPROVAL_FIELD_CHARS}. */
1087
+ function approvalField(text) {
1088
+ return text.length <= APPROVAL_FIELD_CHARS
1089
+ ? text
1090
+ : `${text.slice(0, APPROVAL_FIELD_CHARS - 1)}…`;
1091
+ }
1092
+ /**
1093
+ * The repo tools a person approves before they run: opening a pull request, and
1094
+ * nothing else. Pushing a work branch and commenting run without asking.
1095
+ *
1096
+ * The reason is what the person reads beside Approve and Reject, so it says what
1097
+ * the call will do in their terms: which repository, from which branch into
1098
+ * which, and under what title. The repository is the checkout's origin, read
1099
+ * through the same lookup the tool runs, so a directory named after one
1100
+ * repository cannot present a pull request on another. The input has already
1101
+ * passed the tool's own schema. What a rule is, and whose calls it covers, is
1102
+ * core's to say — see `AgentPlugin.mainAgentToolApproval`.
1103
+ */
1104
+ export function repoToolApproval(config) {
1105
+ return repoSurface(config).approval;
1047
1106
  }
1048
1107
  export function repo(config) {
1049
1108
  return definePlugin({
1050
1109
  key: "repo",
1051
1110
  mainAgentTools: () => buildRepoTools(config),
1111
+ mainAgentToolApproval: () => repoToolApproval(config),
1052
1112
  // The runtime state goes through to `exec` untouched, so a delegated
1053
1113
  // subtask's git commands run in the same container its parent cloned into.
1054
1114
  toolFamilies: {
@@ -144,10 +144,7 @@ export async function shouldReply(model, history, options = {}) {
144
144
  const { output } = await generateText({
145
145
  model,
146
146
  output: Output.object({ schema: triageSchema }),
147
- prompt: buildTriagePrompt(history, options),
148
- // Consistent with the rest of the runtime: we handle failure ourselves
149
- // (here, by failing open), so SDK backoff would only delay the turn.
150
- maxRetries: 0
147
+ prompt: buildTriagePrompt(history, options)
151
148
  });
152
149
  // Log the whole verdict, not just the outcome. The booleans are the
153
150
  // reasoning — a correct verdict reached through wrong booleans means the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dynamicagents/plugins",
3
- "version": "0.10.3",
3
+ "version": "0.11.1",
4
4
  "description": "Optional, composable capabilities for a Dynamic Agent: ARC-AGI-3, browser rendering, a Linux container, git and pull requests, workspace, episodic recall, and pre-turn triage. One subpath per plugin, so a bundle grows only with what it imports.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -85,12 +85,12 @@
85
85
  "verify:peer-ranges": "node scripts/verify-peer-ranges.mjs",
86
86
  "prepack": "npm run build && npm run verify:exports",
87
87
  "prepublishOnly": "npm run check && npm test && npm run verify:exports",
88
- "prepare": "husky"
88
+ "prepare": "husky || true && npm run build"
89
89
  },
90
90
  "peerDependencies": {
91
91
  "@cloudflare/computer": "^0.2.1",
92
92
  "@cloudflare/shell": "^0.4.3",
93
- "@dynamicagents/core": ">=0.9.0 <0.11.0",
93
+ "@dynamicagents/core": ">=0.11.0 <0.12.0",
94
94
  "agents": ">=0.21.0 <0.23.0",
95
95
  "ai": "^7.0.77",
96
96
  "drizzle-orm": ">=0.45.2",
@@ -109,7 +109,7 @@
109
109
  "@cloudflare/computer": "^0.2.1",
110
110
  "@cloudflare/shell": "0.4.3",
111
111
  "@cloudflare/vitest-plugin": "^1.1.5",
112
- "@dynamicagents/core": "^0.9.0",
112
+ "@dynamicagents/core": "^0.11.0",
113
113
  "@types/node": "^26.5.0",
114
114
  "agents": ">=0.21.0 <0.23.0",
115
115
  "ai": "^7.0.93",