sakura-agent 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +207 -77
  2. package/README.zh-CN.md +208 -78
  3. package/THIRD_PARTY_NOTICES.md +8 -58
  4. package/dist/packages/agent/src/index.d.ts +49 -11
  5. package/dist/packages/agent/src/index.js +50 -55
  6. package/dist/packages/agent/src/restrict-tools.d.ts +6 -0
  7. package/dist/packages/agent/src/restrict-tools.js +11 -0
  8. package/dist/packages/agent/src/system-prompt.d.ts +5 -0
  9. package/dist/packages/agent/src/system-prompt.js +1 -1
  10. package/dist/packages/agent/src/wake.d.ts +41 -0
  11. package/dist/packages/agent/src/wake.js +71 -0
  12. package/dist/packages/env/src/confine.d.ts +13 -0
  13. package/dist/packages/env/src/confine.js +40 -0
  14. package/dist/packages/env/src/connect-ssh.d.ts +45 -0
  15. package/dist/packages/env/src/connect-ssh.js +242 -0
  16. package/dist/packages/env/src/index.d.ts +6 -1
  17. package/dist/packages/env/src/index.js +3 -1
  18. package/dist/packages/env/src/local.d.ts +11 -2
  19. package/dist/packages/env/src/local.js +42 -10
  20. package/dist/packages/env/src/remote-stream.d.ts +48 -0
  21. package/dist/packages/env/src/remote-stream.js +143 -0
  22. package/dist/packages/env/src/remote-types.d.ts +17 -0
  23. package/dist/packages/env/src/remote-types.js +10 -0
  24. package/dist/packages/env/src/remote.d.ts +16 -7
  25. package/dist/packages/env/src/remote.js +73 -10
  26. package/dist/packages/evals/src/bfcl.d.ts +24 -0
  27. package/dist/packages/evals/src/bfcl.js +71 -0
  28. package/dist/packages/evals/src/index.d.ts +3 -0
  29. package/dist/packages/evals/src/index.js +2 -0
  30. package/dist/packages/evals/src/png.d.ts +2 -0
  31. package/dist/packages/evals/src/png.js +30 -0
  32. package/dist/packages/evals/src/pool.d.ts +3 -0
  33. package/dist/packages/evals/src/pool.js +22 -0
  34. package/dist/packages/evals/src/providers.d.ts +13 -0
  35. package/dist/packages/evals/src/providers.js +23 -0
  36. package/dist/packages/kernel/src/compaction.d.ts +24 -3
  37. package/dist/packages/kernel/src/compaction.js +23 -16
  38. package/dist/packages/kernel/src/goal.d.ts +10 -1
  39. package/dist/packages/kernel/src/goal.js +28 -0
  40. package/dist/packages/kernel/src/hooks.d.ts +5 -0
  41. package/dist/packages/kernel/src/hooks.js +21 -1
  42. package/dist/packages/kernel/src/index.d.ts +3 -2
  43. package/dist/packages/kernel/src/index.js +1 -0
  44. package/dist/packages/kernel/src/loop.d.ts +28 -4
  45. package/dist/packages/kernel/src/loop.js +69 -47
  46. package/dist/packages/kernel/src/types.d.ts +25 -4
  47. package/dist/packages/kernel/src/usage.d.ts +3 -0
  48. package/dist/packages/kernel/src/usage.js +16 -0
  49. package/dist/packages/llm/src/chat/errors.d.ts +11 -0
  50. package/dist/packages/llm/src/chat/errors.js +17 -0
  51. package/dist/packages/llm/src/chat/request.js +22 -4
  52. package/dist/packages/llm/src/chat/retry.d.ts +1 -1
  53. package/dist/packages/llm/src/chat/retry.js +1 -1
  54. package/dist/packages/llm/src/chat/stream.js +2 -1
  55. package/dist/packages/llm/src/index.js +7 -2
  56. package/dist/packages/plugins/context/src/index.d.ts +2 -1
  57. package/dist/packages/plugins/context/src/index.js +2 -1
  58. package/dist/packages/plugins/limits/src/index.d.ts +4 -1
  59. package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
  60. package/dist/packages/plugins/redaction/src/index.js +3 -0
  61. package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
  62. package/dist/packages/plugins/review/src/chain.d.ts +10 -1
  63. package/dist/packages/plugins/review/src/chain.js +7 -4
  64. package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
  65. package/dist/packages/plugins/review/src/hard-rules.js +58 -25
  66. package/dist/packages/plugins/review/src/index.d.ts +21 -0
  67. package/dist/packages/plugins/review/src/index.js +42 -7
  68. package/dist/packages/plugins/review/src/reviewer.d.ts +32 -3
  69. package/dist/packages/plugins/review/src/reviewer.js +115 -27
  70. package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
  71. package/dist/packages/plugins/tools/src/builtin/image.d.ts +11 -0
  72. package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
  73. package/dist/packages/plugins/tools/src/builtin/read.js +26 -12
  74. package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
  75. package/dist/packages/plugins/toolset/src/codemode.js +84 -26
  76. package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
  77. package/dist/packages/plugins/toolset/src/index.js +1 -1
  78. package/dist/packages/plugins/trace/src/index.d.ts +27 -0
  79. package/dist/packages/plugins/trace/src/index.js +29 -0
  80. package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
  81. package/dist/packages/plugins/trace/src/modules.js +28 -0
  82. package/dist/packages/session/src/file-log.d.ts +6 -1
  83. package/dist/packages/session/src/file-log.js +45 -25
  84. package/dist/packages/session/src/fork.d.ts +16 -0
  85. package/dist/packages/session/src/fork.js +70 -0
  86. package/dist/packages/session/src/index.d.ts +2 -0
  87. package/dist/packages/session/src/index.js +1 -0
  88. package/dist/packages/session/src/log-core.d.ts +37 -0
  89. package/dist/packages/session/src/log-core.js +47 -0
  90. package/dist/packages/session/src/memory-log.d.ts +3 -12
  91. package/dist/packages/session/src/memory-log.js +3 -23
  92. package/dist/packages/session/src/projection.js +22 -3
  93. package/dist/packages/testing/src/faux.d.ts +36 -0
  94. package/dist/packages/testing/src/faux.js +69 -0
  95. package/dist/packages/testing/src/index.d.ts +4 -0
  96. package/dist/packages/testing/src/index.js +2 -0
  97. package/dist/packages/testing/src/rng.d.ts +7 -0
  98. package/dist/packages/testing/src/rng.js +17 -0
  99. package/dist/sdk/index.d.ts +11 -3
  100. package/dist/sdk/index.js +5 -1
  101. package/dist/sdk/testing.d.ts +2 -0
  102. package/dist/sdk/testing.js +2 -0
  103. package/package.json +7 -2
@@ -1,6 +1,6 @@
1
- import type { CompactionConfig } from "./compaction.js";
1
+ import type { CompactionConfig, ContextStatus } from "./compaction.js";
2
2
  import type { Prompts } from "./prompts.js";
3
- import type { Content, Environment, Event, LlmClient, Log, PauseReason, Plugin, RunEndReason, ThinkingLevel, Tool } from "./types.js";
3
+ import type { Content, Environment, Event, GoalRecord, LlmClient, Log, PauseReason, Plugin, RunEndReason, ThinkingLevel, Tool, UsageTotals } from "./types.js";
4
4
  export interface KernelConfig {
5
5
  llm: LlmClient;
6
6
  env: Environment;
@@ -19,7 +19,9 @@ export interface KernelConfig {
19
19
  };
20
20
  prompts?: Partial<Prompts>;
21
21
  /** Called whenever a tool or plugin reports a change, even when no run is active, so the caller can decide to start one. */
22
- onSignal?(log: Log, text: string): void;
22
+ onSignal?(log: Log, text: string, options?: {
23
+ wake?: boolean;
24
+ }): void;
23
25
  }
24
26
  export type UserInput = string | Content[];
25
27
  export interface RunResult {
@@ -27,6 +29,15 @@ export interface RunResult {
27
29
  pause?: PauseReason;
28
30
  error?: string;
29
31
  }
32
+ export interface RunOptions {
33
+ /**
34
+ * Compact the history before anything else in this run, whatever its size (a person asked for it). It happens after an interrupted run has been
35
+ * put right and before the new input, if any, is added, so that input is not part of the summary. Without input the run ends after the compaction,
36
+ * unless the conversation ends with something the model has not answered. Works also when automatic compaction is off; then the defaults apply.
37
+ * Whether it happened shows as a `compaction` event; there is none when there was nothing worth summarising.
38
+ */
39
+ compact?: boolean;
40
+ }
30
41
  export interface RunHandle {
31
42
  events: AsyncIterable<Event>;
32
43
  result: Promise<RunResult>;
@@ -41,11 +52,24 @@ export interface Kernel {
41
52
  * The single entry for starting, resuming and following up. The tail of the log decides what happens:
42
53
  * unanswered user input or tool results go to the model, a finished reply ends the run immediately.
43
54
  */
44
- run(log: Log, input?: UserInput): RunHandle;
55
+ run(log: Log, input?: UserInput, options?: RunOptions): RunHandle;
45
56
  /** What the agent has not been told yet, and the background work it is still waiting for. Lets a host wake an agent that went quiet. */
46
57
  pending(log: Log): {
47
58
  signals: string[];
48
59
  watching: string[];
49
60
  };
61
+ /** The goal of the conversation, from the log: undefined when there never was one. */
62
+ goal(log: Log): Promise<GoalRecord | undefined>;
63
+ /** Sets the goal of the conversation, replacing an earlier one. It is checked before a run may stop. Fails while a run is active. */
64
+ setGoal(log: Log, goal: {
65
+ objective: string;
66
+ verify?: string;
67
+ }): Promise<void>;
68
+ /** Ends the goal: it counts as complete and is not checked any more. Fails while a run is active. */
69
+ clearGoal(log: Log): Promise<void>;
70
+ /** How full the conversation is: tokens used, the window, what is free, and where compaction starts. */
71
+ context(log: Log): Promise<ContextStatus>;
72
+ /** What the conversation has spent so far, counted from the log. */
73
+ usage(log: Log): Promise<UsageTotals>;
50
74
  }
51
75
  export declare function createKernel(config: KernelConfig): Kernel;
@@ -1,11 +1,13 @@
1
1
  import { createChannel } from "./channel.js";
2
- import { contextTokens, defaultCompaction, planCompaction, shouldCompact } from "./compaction.js";
3
- import { BLOCKED_AFTER, describeRemaining, judgeChecklist, newGoal, parseChecklist } from "./goal.js";
2
+ import { contextStatus, contextTokens, defaultCompaction, planCompaction, shouldCompact } from "./compaction.js";
3
+ import { usageTotals } from "./usage.js";
4
+ import { BLOCKED_AFTER, currentGoal, describeRemaining, endGoal, judgeChecklist, parseChecklist, startGoal } from "./goal.js";
4
5
  import { defaultPrompts, fill } from "./prompts.js";
5
6
  import { createHookSet } from "./hooks.js";
6
7
  import { diffSystem, renderSystem, replaySystem } from "./system.js";
7
8
  import { ProviderError } from "./types.js";
8
9
  const CANCELLED_RESULT = "The run was cancelled before this tool finished.";
10
+ const INTERRUPTED_NOTE = "[The user interrupted this request before it was answered.]";
9
11
  const INTERRUPTED_RESULT = "This tool was interrupted and may have partially run. Check the current state before deciding what to do.";
10
12
  const TRUNCATED_RESULT = "Your output was cut off before this tool call was complete, so it was not run. Split the work into smaller parts and try again.";
11
13
  function isAbort(error) {
@@ -25,28 +27,15 @@ async function* throughTransforms(transforms, source) {
25
27
  for (const [index, transform] of transforms.entries())
26
28
  yield* run(transform.flush(), index + 1);
27
29
  }
28
- function usageTotals(branch) {
29
- const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 };
30
- for (const entry of branch) {
31
- const usage = entry.type === "message" ? entry.message.usage : entry.type === "usage" ? entry.usage : undefined;
32
- if (entry.type === "message" && entry.message.role === "assistant")
33
- totals.turns++;
34
- if (!usage)
35
- continue;
36
- totals.input += usage.input;
37
- totals.output += usage.output;
38
- totals.cacheRead += usage.cacheRead;
39
- totals.cacheWrite += usage.cacheWrite;
40
- }
41
- return totals;
42
- }
43
30
  function toolCallsOf(message) {
44
31
  return message.content.flatMap((part) => (part.type === "tool_call" ? [{ id: part.id, name: part.name, args: part.args }] : []));
45
32
  }
46
33
  export function createKernel(config) {
47
34
  const now = config.now ?? Date.now;
48
35
  const prompts = { ...defaultPrompts, ...config.prompts };
49
- const compactionConfig = config.compaction === false ? undefined : { ...defaultCompaction, ...config.compaction };
36
+ // a caller who gives `reserveTokens` (and no share) gets exactly that, as before; the share is for the default
37
+ const given = config.compaction === false ? undefined : config.compaction;
38
+ const compactionConfig = config.compaction === false ? undefined : { ...defaultCompaction, ...(given?.reserveTokens !== undefined && given.reserveFraction === undefined ? { reserveFraction: 0 } : {}), ...given };
50
39
  const activeLogs = new WeakSet();
51
40
  // Plugins are set up once. Failures of their handlers are reported to the runs that are active.
52
41
  const failureReporters = new Set();
@@ -62,7 +51,7 @@ export function createKernel(config) {
62
51
  send(plugin, data);
63
52
  });
64
53
  const toolByName = new Map(hooks.tools.map((tool) => [tool.name, tool]));
65
- function run(log, input) {
54
+ function run(log, input, runOptions = {}) {
66
55
  if (activeLogs.has(log))
67
56
  throw new Error("this log already has an active run");
68
57
  activeLogs.add(log);
@@ -85,6 +74,11 @@ export function createKernel(config) {
85
74
  async function appendMessage(message) {
86
75
  return log.append({ type: "message", message });
87
76
  }
77
+ /** A message from the person, added while the run goes on: the screen is told when the agent takes it. */
78
+ async function appendAndAnnounce(message) {
79
+ await appendMessage(message);
80
+ emit({ type: "message_end", message });
81
+ }
88
82
  function userMessage(content) {
89
83
  return { role: "user", content, ts: now() };
90
84
  }
@@ -109,22 +103,22 @@ export function createKernel(config) {
109
103
  systemState = replaySystem(await log.branch());
110
104
  }
111
105
  // ----- signals -----
112
- const reportChange = (text) => {
106
+ const reportChange = (text, wake) => {
113
107
  const list = pendingSignals.get(log) ?? [];
114
108
  list.push(text);
115
109
  pendingSignals.set(log, list);
116
- config.onSignal?.(log, text);
110
+ config.onSignal?.(log, text, wake === undefined ? undefined : { wake });
117
111
  if (!finished)
118
112
  emit({ type: "signal", text });
119
113
  };
120
- const watchWork = (label) => {
114
+ const watchWork = (label, options) => {
121
115
  const entry = { label };
122
116
  const set = watchedWork.get(log) ?? new Set();
123
117
  set.add(entry);
124
118
  watchedWork.set(log, set);
125
- return (text) => {
119
+ return (text, ending) => {
126
120
  if (set.delete(entry))
127
- reportChange(text);
121
+ reportChange(text, ending?.wake ?? options?.wake);
128
122
  };
129
123
  };
130
124
  /** Tells the agent what changed, as one message in a fixed form. */
@@ -183,6 +177,13 @@ export function createKernel(config) {
183
177
  await Promise.all(parallel);
184
178
  }
185
179
  /** After a cancel or a crash: give every call without a result an error result so the log stays valid. */
180
+ /** A cancelled request is closed with a note, so the next message is not answered together with it. */
181
+ async function noteInterruption() {
182
+ const last = (await log.project()).at(-1);
183
+ if (!last || last.role === "assistant")
184
+ return;
185
+ await appendMessage({ role: "assistant", content: [{ type: "text", text: INTERRUPTED_NOTE }], ts: now(), stop: "stop" });
186
+ }
186
187
  async function closeOpenCalls(text) {
187
188
  const branch = await log.branch();
188
189
  const answered = new Set(branch.flatMap((entry) => (entry.type === "message" && entry.message.toolCallId ? [entry.message.toolCallId] : [])));
@@ -235,7 +236,18 @@ export function createKernel(config) {
235
236
  let truncated;
236
237
  let usage;
237
238
  let stop = "stop";
239
+ let announced = false;
240
+ const announce = () => {
241
+ if (announced)
242
+ return;
243
+ announced = true;
244
+ emit({ type: "message_start", message: { role: "assistant", content: [], ts: now() } });
245
+ };
238
246
  for await (const event of throughTransforms(prepared.transforms, config.llm.stream(request, controller.signal))) {
247
+ if (event.type === "text_delta" || event.type === "reasoning_delta" || event.type === "provider" || event.type === "tool_call")
248
+ announce();
249
+ if (event.type === "reasoning_delta")
250
+ emit({ type: "reasoning_update", delta: event.delta });
239
251
  if (event.type === "text_delta") {
240
252
  text += event.delta;
241
253
  emit({ type: "message_update", delta: { type: "text", text: event.delta } });
@@ -259,6 +271,7 @@ export function createKernel(config) {
259
271
  truncated = undefined;
260
272
  usage = undefined;
261
273
  stop = "stop";
274
+ announced = false;
262
275
  emit({ type: "message_reset" });
263
276
  }
264
277
  else if (event.type === "done") {
@@ -273,7 +286,7 @@ export function createKernel(config) {
273
286
  if (truncated)
274
287
  content.push({ type: "tool_call", id: truncated.id, name: truncated.name, args: {} });
275
288
  const message = { role: "assistant", content, ts: now(), stop, ...(usage ? { usage } : {}) };
276
- return { message, calls, truncated };
289
+ return { message, calls, truncated, announced };
277
290
  }
278
291
  // ----- side requests: compaction and the goal check -----
279
292
  /** Asks the model something about the conversation without letting it act: tool calls are ignored. */
@@ -301,11 +314,12 @@ export function createKernel(config) {
301
314
  return { text, usage };
302
315
  }
303
316
  /** Replaces old history with a summary. Returns false when nothing was compacted. */
304
- async function compact() {
305
- if (!compactionConfig)
317
+ async function compact(forced = false) {
318
+ const settings = compactionConfig ?? (forced ? defaultCompaction : undefined);
319
+ if (!settings)
306
320
  return false;
307
321
  const branch = await log.branch();
308
- const plan = planCompaction(branch, compactionConfig);
322
+ const plan = planCompaction(branch, settings);
309
323
  if (!plan)
310
324
  return false;
311
325
  const previous = [...branch].reverse().find((entry) => entry.type === "compaction");
@@ -330,9 +344,9 @@ export function createKernel(config) {
330
344
  return true;
331
345
  }
332
346
  /** A failed compaction must not stop the run; the caller carries on with the full context. */
333
- async function tryCompact() {
347
+ async function tryCompact(forced = false) {
334
348
  try {
335
- return await compact();
349
+ return await compact(forced);
336
350
  }
337
351
  catch (error) {
338
352
  if (cancelled || isAbort(error))
@@ -341,21 +355,10 @@ export function createKernel(config) {
341
355
  return false;
342
356
  }
343
357
  }
344
- function currentGoal(branch) {
345
- const entry = [...branch].reverse().find((candidate) => candidate.type === "goal");
346
- return entry?.type === "goal" ? entry.goal : undefined;
347
- }
348
358
  async function openGoal() {
349
359
  if (!config.goal || currentGoal(await log.branch()))
350
360
  return;
351
- const goal = newGoal(config.goal.objective, config.goal.verify);
352
- await log.append({ type: "goal", goal });
353
- await log.append({
354
- type: "custom_message",
355
- customType: "goal",
356
- pinned: true,
357
- content: [{ type: "text", text: fill(prompts.goalStatement, { objective: goal.objective }) }],
358
- });
361
+ await startGoal(log, config.goal, fill(prompts.goalStatement, { objective: config.goal.objective }));
359
362
  }
360
363
  /** Runs when the agent wants to stop. Returns what the run should do next. */
361
364
  async function wrapUp() {
@@ -406,6 +409,8 @@ export function createKernel(config) {
406
409
  await plugin.prepare?.({ log, env: config.env });
407
410
  await recover();
408
411
  await openGoal();
412
+ if (runOptions.compact)
413
+ await tryCompact(true);
409
414
  if (input !== undefined) {
410
415
  await deliverSignals();
411
416
  const message = userMessage(toContent(input));
@@ -421,7 +426,7 @@ export function createKernel(config) {
421
426
  if (cancelled)
422
427
  return { reason: "cancelled" };
423
428
  for (const text of steered.splice(0))
424
- await appendMessage(userMessage([{ type: "text", text }]));
429
+ await appendAndAnnounce(userMessage([{ type: "text", text }]));
425
430
  let messages = await log.project();
426
431
  if (compactionConfig && shouldCompact(messages, config.llm.model.contextWindow, compactionConfig) && (await tryCompact())) {
427
432
  messages = await log.project();
@@ -438,7 +443,7 @@ export function createKernel(config) {
438
443
  const next = queued.shift();
439
444
  if (next === undefined)
440
445
  return { reason: "done" };
441
- await appendMessage(userMessage([{ type: "text", text: next }]));
446
+ await appendAndAnnounce(userMessage([{ type: "text", text: next }]));
442
447
  continue;
443
448
  }
444
449
  if (await deliverSignals())
@@ -467,7 +472,8 @@ export function createKernel(config) {
467
472
  throw error;
468
473
  }
469
474
  overflowRetried = false;
470
- emit({ type: "message_start", message: turn.message });
475
+ if (!turn.announced)
476
+ emit({ type: "message_start", message: turn.message });
471
477
  await appendMessage(turn.message);
472
478
  emit({ type: "message_end", message: turn.message });
473
479
  if (turn.truncated) {
@@ -504,10 +510,13 @@ export function createKernel(config) {
504
510
  outcome = await drive();
505
511
  }
506
512
  catch (error) {
507
- outcome = { reason: "error", error: error instanceof Error ? error.message : String(error) };
513
+ // a cancel that reaches a step with no handling of its own (summarising, for one) stops the run; it is a cancel and not a failure
514
+ outcome = cancelled ? { reason: "cancelled" } : { reason: "error", error: error instanceof Error ? error.message : String(error) };
508
515
  }
509
- if (outcome.reason === "cancelled")
516
+ if (outcome.reason === "cancelled") {
510
517
  await closeOpenCalls(CANCELLED_RESULT).catch(() => undefined);
518
+ await noteInterruption().catch(() => undefined);
519
+ }
511
520
  emit({ type: "run_end", reason: outcome.reason });
512
521
  channel.end();
513
522
  failureReporters.delete(reportFailure);
@@ -529,6 +538,19 @@ export function createKernel(config) {
529
538
  }
530
539
  return {
531
540
  run,
541
+ goal: async (log) => currentGoal(await log.branch()),
542
+ setGoal: async (log, goal) => {
543
+ if (activeLogs.has(log))
544
+ throw new Error("this log already has an active run");
545
+ await startGoal(log, goal, fill(prompts.goalStatement, { objective: goal.objective }));
546
+ },
547
+ clearGoal: async (log) => {
548
+ if (activeLogs.has(log))
549
+ throw new Error("this log already has an active run");
550
+ await endGoal(log);
551
+ },
552
+ context: async (log) => contextStatus(await log.project(), config.llm.model.contextWindow, compactionConfig),
553
+ usage: async (log) => usageTotals(await log.branch()),
532
554
  pending: (log) => ({ signals: [...(pendingSignals.get(log) ?? [])], watching: [...(watchedWork.get(log) ?? [])].map((entry) => entry.label) }),
533
555
  };
534
556
  }
@@ -190,15 +190,23 @@ export interface ProcessHandle {
190
190
  pid: number;
191
191
  /** File the output is written to as it arrives. */
192
192
  outputFile: string;
193
- /** Resolves when the command has finished and its output has been written. */
193
+ /**
194
+ * Resolves when the command has finished and its output has been written. Rejects only when the environment lost contact with the command and
195
+ * cannot say how it ended (a remote machine that stopped answering); the message says so, and where the output is being written.
196
+ */
194
197
  wait(): Promise<ExecResult>;
195
198
  /** Stops the command and everything it started. */
196
199
  kill(): void;
197
200
  readonly finished: boolean;
198
201
  }
199
- /** Where tools run. The local implementation has no sandbox; sandboxes arrive in v2. */
202
+ /** Where tools run. The local implementation has no sandbox: to confine commands or run them elsewhere, an application supplies its own Environment. */
200
203
  export interface Environment {
201
204
  cwd: string;
205
+ /**
206
+ * What the model should know about where its tools run, when that is not simply this machine: it is put into the system prompt. A remote machine, for
207
+ * example, says that commands go over a connection that can fail. Left out for an environment that is just the local machine.
208
+ */
209
+ description?: string;
202
210
  resolve(path: string): string;
203
211
  readFile(path: string): Promise<Uint8Array>;
204
212
  writeFile(path: string, data: Uint8Array | string): Promise<void>;
@@ -227,8 +235,16 @@ export interface ToolContext {
227
235
  log: Log;
228
236
  /** Tells the agent that something changed (for example a background command finished). Delivered before its next request. */
229
237
  notify(text: string): void;
230
- /** Says that something is going on in the background. Call the returned function with the text to tell the agent when it ends; until then the work counts as pending. */
231
- watch(label: string): (text: string) => void;
238
+ /**
239
+ * Says that something is going on in the background. Call the returned function with the text to tell the agent when it ends; until then the work counts as pending.
240
+ * `wake` says whether the end should wake an agent that has gone quiet (the host decides what that means); left out, the host's own setting applies.
241
+ * The function that reports the end may take its own `wake`, which wins for that ending (work that was stopped because the run was cancelled should not wake the agent).
242
+ */
243
+ watch(label: string, options?: {
244
+ wake?: boolean;
245
+ }): (text: string, ending?: {
246
+ wake?: boolean;
247
+ }) => void;
232
248
  }
233
249
  export interface Tool {
234
250
  name: string;
@@ -257,6 +273,11 @@ export type Event = {
257
273
  type: "message_update";
258
274
  delta: Content;
259
275
  }
276
+ /** A piece of the model's reasoning, while it reasons. Only for showing: it is not kept in the log and not sent again. */
277
+ | {
278
+ type: "reasoning_update";
279
+ delta: string;
280
+ }
260
281
  /** The reply that was being streamed is void (the request was sent again after a dropped connection). */
261
282
  | {
262
283
  type: "message_reset";
@@ -0,0 +1,3 @@
1
+ import type { LogEntry, UsageTotals } from "./types.js";
2
+ /** Everything the session has spent, counted from the log (so it survives restarts). */
3
+ export declare function usageTotals(branch: readonly LogEntry[]): UsageTotals;
@@ -0,0 +1,16 @@
1
+ /** Everything the session has spent, counted from the log (so it survives restarts). */
2
+ export function usageTotals(branch) {
3
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 };
4
+ for (const entry of branch) {
5
+ const usage = entry.type === "message" ? entry.message.usage : entry.type === "usage" ? entry.usage : undefined;
6
+ if (entry.type === "message" && entry.message.role === "assistant")
7
+ totals.turns++;
8
+ if (!usage)
9
+ continue;
10
+ totals.input += usage.input;
11
+ totals.output += usage.output;
12
+ totals.cacheRead += usage.cacheRead;
13
+ totals.cacheWrite += usage.cacheWrite;
14
+ }
15
+ return totals;
16
+ }
@@ -1,6 +1,17 @@
1
1
  import { ProviderError } from "../../../kernel/src/index.js";
2
2
  /** Maps an HTTP error response to a ProviderError. */
3
3
  export declare function classifyHttpError(status: number, bodyText: string, headers: Headers): ProviderError;
4
+ /**
5
+ * An error sent inside a stream that already started (the HTTP status was 200). It is classified like the same error would be as an HTTP status:
6
+ * the status comes from the frame's `code` or `status`, or from a bracketed number in the text ("[500] EngineCore ..."). Without a status, a type
7
+ * that names a server-side failure counts as overloaded; anything else stays "other" and is not retried, because a request that is wrong would be wrong again.
8
+ */
9
+ export declare function classifyStreamError(error: {
10
+ message?: string;
11
+ code?: unknown;
12
+ status?: unknown;
13
+ type?: unknown;
14
+ }): ProviderError;
4
15
  export declare const isRetryable: (error: ProviderError) => boolean;
5
16
  /** The connection is open but nothing has arrived for too long. */
6
17
  export declare class StalledStreamError extends ProviderError {
@@ -34,6 +34,23 @@ export function classifyHttpError(status, bodyText, headers) {
34
34
  return new ProviderError("quota", message, status);
35
35
  return new ProviderError("invalid_request", message, status);
36
36
  }
37
+ const BRACKETED_STATUS = /\[(\d{3})\]/;
38
+ const SERVER_SIDE_TYPE = /server_error|overloaded|internal/i;
39
+ /**
40
+ * An error sent inside a stream that already started (the HTTP status was 200). It is classified like the same error would be as an HTTP status:
41
+ * the status comes from the frame's `code` or `status`, or from a bracketed number in the text ("[500] EngineCore ..."). Without a status, a type
42
+ * that names a server-side failure counts as overloaded; anything else stays "other" and is not retried, because a request that is wrong would be wrong again.
43
+ */
44
+ export function classifyStreamError(error) {
45
+ const message = error.message?.trim() || "the provider reported an error in the stream";
46
+ const given = Number(error.code ?? error.status);
47
+ const status = Number.isInteger(given) && given >= 400 && given < 600 ? given : Number(BRACKETED_STATUS.exec(message)?.[1]);
48
+ if (status >= 400 && status < 600)
49
+ return classifyHttpError(status, message, new Headers());
50
+ if (typeof error.type === "string" && SERVER_SIDE_TYPE.test(error.type))
51
+ return new ProviderError("overloaded", message);
52
+ return new ProviderError("other", message);
53
+ }
37
54
  export const isRetryable = (error) => error.kind === "rate_limit" || error.kind === "overloaded" || error.kind === "network";
38
55
  /** The connection is open but nothing has arrived for too long. */
39
56
  export class StalledStreamError extends ProviderError {
@@ -1,4 +1,13 @@
1
1
  import { thinkingParameters } from "./compat.js";
2
+ /** The URL for an image, or a sentence for the model when it cannot be read (the file is gone, or it is on another machine): one bad image must not stop every later request. */
3
+ async function imageUrl(context, ref, mediaType) {
4
+ try {
5
+ return { url: await context.resolveImage(ref, mediaType) };
6
+ }
7
+ catch (error) {
8
+ return { note: `[an image could not be read: ${ref} (${error instanceof Error ? error.message : String(error)})]` };
9
+ }
10
+ }
2
11
  const textOf = (content) => content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
3
12
  async function userParts(content, context) {
4
13
  const parts = [];
@@ -7,7 +16,8 @@ async function userParts(content, context) {
7
16
  parts.push({ type: "text", text: part.text });
8
17
  else if (part.type === "image") {
9
18
  if (context.compat.images) {
10
- parts.push({ type: "image_url", image_url: { url: await context.resolveImage(part.ref, part.mediaType) } });
19
+ const resolved = await imageUrl(context, part.ref, part.mediaType);
20
+ parts.push("url" in resolved ? { type: "image_url", image_url: { url: resolved.url } } : { type: "text", text: resolved.note });
11
21
  }
12
22
  else {
13
23
  parts.push({ type: "text", text: "[an image was attached but this model cannot read images]" });
@@ -60,12 +70,20 @@ export async function toWireRequest(request, context) {
60
70
  let content = textOf(message.content);
61
71
  if (images.length > 0) {
62
72
  if (compat.images) {
73
+ let attached = 0;
63
74
  for (const image of images) {
64
- if (image.type === "image") {
65
- pendingImages.push({ type: "image_url", image_url: { url: await context.resolveImage(image.ref, image.mediaType) } });
75
+ if (image.type !== "image")
76
+ continue;
77
+ const resolved = await imageUrl(context, image.ref, image.mediaType);
78
+ if ("url" in resolved) {
79
+ pendingImages.push({ type: "image_url", image_url: { url: resolved.url } });
80
+ attached++;
66
81
  }
82
+ else
83
+ content = content ? `${content}\n${resolved.note}` : resolved.note;
67
84
  }
68
- content = content ? `${content}\n(see attached image)` : "(see attached image)";
85
+ if (attached > 0)
86
+ content = content ? `${content}\n(see attached image)` : "(see attached image)";
69
87
  }
70
88
  else {
71
89
  content = content ? `${content}\n[an image was returned but this model cannot read images]` : "[an image was returned but this model cannot read images]";
@@ -6,7 +6,7 @@ export interface RetryPolicy {
6
6
  maxDelayMs: number;
7
7
  /** A server-requested wait longer than this is not honoured; the error is returned instead. */
8
8
  maxServerDelayMs: number;
9
- /** A stalled stream is tried again only this many times. */
9
+ /** A stalled stream (silence for the idle timeout) is tried again this many times; each stall costs the idle timeout, so this bounds the waiting. */
10
10
  maxStallRetries: number;
11
11
  sleep(ms: number, signal: AbortSignal): Promise<void>;
12
12
  random(): number;
@@ -5,7 +5,7 @@ export const defaultRetry = {
5
5
  baseDelayMs: 500,
6
6
  maxDelayMs: 32_000,
7
7
  maxServerDelayMs: 60_000,
8
- maxStallRetries: 1,
8
+ maxStallRetries: 4,
9
9
  sleep: (ms, signal) => new Promise((resolve, reject) => {
10
10
  if (signal.aborted)
11
11
  return reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
@@ -1,4 +1,5 @@
1
1
  import { ProviderError } from "../../../kernel/src/index.js";
2
+ import { classifyStreamError } from "./errors.js";
2
3
  /** Input excludes cached tokens, so the four numbers add up to what was processed. */
3
4
  export function normalizeUsage(raw) {
4
5
  const cached = raw.prompt_cache_hit_tokens ?? raw.prompt_tokens_details?.cached_tokens ?? 0;
@@ -62,7 +63,7 @@ export async function* parseChatStream(body, compat) {
62
63
  throw new ProviderError("network", "the provider sent a malformed stream chunk");
63
64
  }
64
65
  if (chunk.error)
65
- throw new ProviderError("other", chunk.error.message ?? "the provider reported an error in the stream");
66
+ throw classifyStreamError(chunk.error);
66
67
  if (chunk.usage)
67
68
  usage = normalizeUsage(chunk.usage);
68
69
  const choice = chunk.choices?.[0];
@@ -16,8 +16,13 @@ async function* watchIdle(source, ms, cancel) {
16
16
  const stalled = new Promise((resolve) => {
17
17
  timer = setTimeout(() => resolve("stalled"), ms);
18
18
  });
19
- const result = await Promise.race([next, stalled]);
20
- clearTimeout(timer);
19
+ let result;
20
+ try {
21
+ result = await Promise.race([next, stalled]);
22
+ }
23
+ finally {
24
+ clearTimeout(timer); // also when the read fails (a cancel, a broken connection): a timer left running keeps the process alive
25
+ }
21
26
  if (result === "stalled") {
22
27
  cancel();
23
28
  throw new StalledStreamError(Math.round(ms / 1000));
@@ -21,6 +21,7 @@ export interface ContextPlugin extends Plugin {
21
21
  /**
22
22
  * Puts project instructions (AGENTS.md) at the start of the conversation as a pinned message, once.
23
23
  * They survive compaction and are not summarised. Only the AGENTS.md name is understood; there is no
24
- * parent-directory search, no per-directory loading and no @path includes.
24
+ * parent-directory search, no per-directory loading and no @path includes. A changed file replaces the instructions after the next compaction;
25
+ * a file that is removed or emptied does not take them back.
25
26
  */
26
27
  export declare function contextPlugin(options?: ContextOptions): ContextPlugin;
@@ -13,7 +13,8 @@ async function readSource(env, source, maxBytes) {
13
13
  /**
14
14
  * Puts project instructions (AGENTS.md) at the start of the conversation as a pinned message, once.
15
15
  * They survive compaction and are not summarised. Only the AGENTS.md name is understood; there is no
16
- * parent-directory search, no per-directory loading and no @path includes.
16
+ * parent-directory search, no per-directory loading and no @path includes. A changed file replaces the instructions after the next compaction;
17
+ * a file that is removed or emptied does not take them back.
17
18
  */
18
19
  export function contextPlugin(options = {}) {
19
20
  const fileName = options.fileName ?? "AGENTS.md";
@@ -8,7 +8,10 @@ export interface Budget {
8
8
  maxTokens?: number;
9
9
  /** Stop after this much spend in the session; needs `prices`. */
10
10
  maxCostUsd?: number;
11
- /** Stop a single run that has been going this long. */
11
+ /**
12
+ * Stop a single run that has been going this long. This is checked at the end of each turn, not during one: a turn that is under way (a model
13
+ * request, a command) finishes first, and what stops those is their own limits. So it is a budget, not a hard deadline.
14
+ */
12
15
  maxRunSeconds?: number;
13
16
  }
14
17
  export interface LimitsOptions extends Budget {
@@ -20,6 +20,9 @@ export interface RedactionPlugin extends Plugin {
20
20
  * Keeps secrets away from the model. Every request is scanned; secrets become stable placeholders, and the model's
21
21
  * reply (text and tool arguments) gets the real values back before anything runs or is saved. If scanning fails the
22
22
  * request is not sent. Off unless you add it.
23
+ *
24
+ * It protects what is sent to the model provider, not what is stored here: the log (the session file), the events and any trace keep the real values,
25
+ * because the tools need them and a resumed session is read from the log. Use `scrub` on anything you export or share.
23
26
  */
24
27
  export declare function redactionPlugin(options?: RedactionOptions): RedactionPlugin;
25
28
  export type { SecretRule } from "./engine.js";
@@ -9,6 +9,9 @@ const MAX_REMEMBERED = 5000;
9
9
  * Keeps secrets away from the model. Every request is scanned; secrets become stable placeholders, and the model's
10
10
  * reply (text and tool arguments) gets the real values back before anything runs or is saved. If scanning fails the
11
11
  * request is not sent. Off unless you add it.
12
+ *
13
+ * It protects what is sent to the model provider, not what is stored here: the log (the session file), the events and any trace keep the real values,
14
+ * because the tools need them and a resumed session is read from the log. Use `scrub` on anything you export or share.
12
15
  */
13
16
  export function redactionPlugin(options = {}) {
14
17
  const engine = createEngine({ ...(options.key ? { key: options.key } : {}), ...(options.rules ? { rules: options.rules } : {}) });
@@ -1,4 +1,4 @@
1
- // Generated by scripts/gen-secret-rules.py from the gitleaks rule table.
1
+ // Generated from the gitleaks rule table.
2
2
  // gitleaks: https://github.com/gitleaks/gitleaks, MIT License, Copyright (c) 2019 Zachary Rice.
3
3
  // The licence text is in licenses/gitleaks-MIT.txt. Do not edit by hand.
4
4
  export const gitleaksRules = [