sakura-agent 0.1.0 → 0.1.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.
Files changed (91) hide show
  1. package/README.md +144 -78
  2. package/README.zh-CN.md +145 -79
  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 +33 -0
  15. package/dist/packages/env/src/connect-ssh.js +132 -0
  16. package/dist/packages/env/src/index.d.ts +5 -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.d.ts +17 -1
  21. package/dist/packages/env/src/remote.js +54 -4
  22. package/dist/packages/evals/src/bfcl.d.ts +24 -0
  23. package/dist/packages/evals/src/bfcl.js +71 -0
  24. package/dist/packages/evals/src/index.d.ts +3 -0
  25. package/dist/packages/evals/src/index.js +2 -0
  26. package/dist/packages/evals/src/png.d.ts +2 -0
  27. package/dist/packages/evals/src/png.js +30 -0
  28. package/dist/packages/evals/src/pool.d.ts +3 -0
  29. package/dist/packages/evals/src/pool.js +22 -0
  30. package/dist/packages/evals/src/providers.d.ts +13 -0
  31. package/dist/packages/evals/src/providers.js +23 -0
  32. package/dist/packages/kernel/src/compaction.d.ts +19 -0
  33. package/dist/packages/kernel/src/compaction.js +12 -2
  34. package/dist/packages/kernel/src/goal.d.ts +10 -1
  35. package/dist/packages/kernel/src/goal.js +28 -0
  36. package/dist/packages/kernel/src/hooks.d.ts +5 -0
  37. package/dist/packages/kernel/src/hooks.js +21 -1
  38. package/dist/packages/kernel/src/index.d.ts +3 -2
  39. package/dist/packages/kernel/src/index.js +1 -0
  40. package/dist/packages/kernel/src/loop.d.ts +28 -4
  41. package/dist/packages/kernel/src/loop.js +69 -47
  42. package/dist/packages/kernel/src/types.d.ts +25 -4
  43. package/dist/packages/kernel/src/usage.d.ts +3 -0
  44. package/dist/packages/kernel/src/usage.js +16 -0
  45. package/dist/packages/llm/src/chat/request.js +22 -4
  46. package/dist/packages/llm/src/chat/retry.d.ts +1 -1
  47. package/dist/packages/llm/src/chat/retry.js +1 -1
  48. package/dist/packages/llm/src/index.js +7 -2
  49. package/dist/packages/plugins/context/src/index.d.ts +2 -1
  50. package/dist/packages/plugins/context/src/index.js +2 -1
  51. package/dist/packages/plugins/limits/src/index.d.ts +4 -1
  52. package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
  53. package/dist/packages/plugins/redaction/src/index.js +3 -0
  54. package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
  55. package/dist/packages/plugins/review/src/chain.d.ts +2 -0
  56. package/dist/packages/plugins/review/src/chain.js +2 -1
  57. package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
  58. package/dist/packages/plugins/review/src/hard-rules.js +58 -25
  59. package/dist/packages/plugins/review/src/index.d.ts +13 -0
  60. package/dist/packages/plugins/review/src/index.js +9 -5
  61. package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
  62. package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
  63. package/dist/packages/plugins/toolset/src/codemode.js +84 -26
  64. package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
  65. package/dist/packages/plugins/toolset/src/index.js +1 -1
  66. package/dist/packages/plugins/trace/src/index.d.ts +27 -0
  67. package/dist/packages/plugins/trace/src/index.js +29 -0
  68. package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
  69. package/dist/packages/plugins/trace/src/modules.js +28 -0
  70. package/dist/packages/session/src/file-log.d.ts +6 -1
  71. package/dist/packages/session/src/file-log.js +45 -25
  72. package/dist/packages/session/src/fork.d.ts +16 -0
  73. package/dist/packages/session/src/fork.js +70 -0
  74. package/dist/packages/session/src/index.d.ts +2 -0
  75. package/dist/packages/session/src/index.js +1 -0
  76. package/dist/packages/session/src/log-core.d.ts +37 -0
  77. package/dist/packages/session/src/log-core.js +47 -0
  78. package/dist/packages/session/src/memory-log.d.ts +3 -12
  79. package/dist/packages/session/src/memory-log.js +3 -23
  80. package/dist/packages/session/src/projection.js +2 -0
  81. package/dist/packages/testing/src/faux.d.ts +36 -0
  82. package/dist/packages/testing/src/faux.js +69 -0
  83. package/dist/packages/testing/src/index.d.ts +4 -0
  84. package/dist/packages/testing/src/index.js +2 -0
  85. package/dist/packages/testing/src/rng.d.ts +7 -0
  86. package/dist/packages/testing/src/rng.js +17 -0
  87. package/dist/sdk/index.d.ts +11 -3
  88. package/dist/sdk/index.js +5 -1
  89. package/dist/sdk/testing.d.ts +2 -0
  90. package/dist/sdk/testing.js +2 -0
  91. package/package.json +5 -1
@@ -2,6 +2,11 @@ import type { LogEntry, Message } from "./types.js";
2
2
  export interface CompactionConfig {
3
3
  /** Compact when the context is within this many tokens of the model's window. */
4
4
  reserveTokens: number;
5
+ /**
6
+ * Compact when the free part of the window is smaller than this share of it (default 0.17, so at 83% full). The larger of this and
7
+ * `reserveTokens` applies. Left out or not a number between 0 and 1, it counts as 0.
8
+ */
9
+ reserveFraction?: number;
5
10
  /** Roughly how many recent tokens stay word for word after a compaction. */
6
11
  keepRecentTokens: number;
7
12
  }
@@ -10,6 +15,20 @@ export declare const defaultCompaction: CompactionConfig;
10
15
  export declare function estimateTokens(message: Message): number;
11
16
  /** Tokens in the context now: the last reported usage plus an estimate for what came after it. */
12
17
  export declare function contextTokens(messages: readonly Message[]): number;
18
+ /** How many tokens of the window must stay free: the larger of the fixed amount and the share of the window. */
19
+ export declare function reservedTokens(contextWindow: number, config: CompactionConfig): number;
20
+ /** How full the conversation is, for a host that shows it. */
21
+ export interface ContextStatus {
22
+ /** Tokens in the context now (see contextTokens). */
23
+ tokens: number;
24
+ /** The model's window. */
25
+ window: number;
26
+ /** What is left of the window; never below zero. */
27
+ free: number;
28
+ /** The size at which the conversation is compacted by itself; undefined when automatic compaction is off. */
29
+ compactAt: number | undefined;
30
+ }
31
+ export declare function contextStatus(messages: readonly Message[], window: number, config: CompactionConfig | undefined): ContextStatus;
13
32
  export declare function shouldCompact(messages: readonly Message[], contextWindow: number, config: CompactionConfig): boolean;
14
33
  export interface CompactionPlan {
15
34
  /** Id of the first entry that stays word for word. */
@@ -1,4 +1,4 @@
1
- export const defaultCompaction = { reserveTokens: 16_384, keepRecentTokens: 20_000 };
1
+ export const defaultCompaction = { reserveTokens: 16_384, reserveFraction: 0.17, keepRecentTokens: 20_000 };
2
2
  /** A rough token count: four characters per token, a flat cost for images. */
3
3
  export function estimateTokens(message) {
4
4
  let characters = 0;
@@ -27,8 +27,18 @@ export function contextTokens(messages) {
27
27
  const base = reported ? reported.input + reported.output + reported.cacheRead + reported.cacheWrite : 0;
28
28
  return messages.slice(lastUsage + 1).reduce((sum, message) => sum + estimateTokens(message), base);
29
29
  }
30
+ /** How many tokens of the window must stay free: the larger of the fixed amount and the share of the window. */
31
+ export function reservedTokens(contextWindow, config) {
32
+ const share = config.reserveFraction;
33
+ const fraction = typeof share === "number" && share > 0 && share < 1 ? share : 0;
34
+ return Math.max(config.reserveTokens, Math.ceil(contextWindow * fraction));
35
+ }
36
+ export function contextStatus(messages, window, config) {
37
+ const tokens = contextTokens(messages);
38
+ return { tokens, window, free: Math.max(0, window - tokens), compactAt: config ? window - reservedTokens(window, config) : undefined };
39
+ }
30
40
  export function shouldCompact(messages, contextWindow, config) {
31
- return messages.length > 1 && contextTokens(messages) > contextWindow - config.reserveTokens;
41
+ return messages.length > 1 && contextTokens(messages) > contextWindow - reservedTokens(contextWindow, config);
32
42
  }
33
43
  /**
34
44
  * Chooses where to cut. History before the cut is summarised, the rest stays. The cut is placed at a user
@@ -1,4 +1,4 @@
1
- import type { GoalRecord } from "./types.js";
1
+ import type { GoalRecord, Log, LogEntry } from "./types.js";
2
2
  export type ItemStatus = "done" | "todo" | "human";
3
3
  export interface ChecklistItem {
4
4
  status: ItemStatus;
@@ -24,3 +24,12 @@ export declare function judgeChecklist(items: readonly ChecklistItem[]): GoalVer
24
24
  export declare const BLOCKED_AFTER = 3;
25
25
  export declare function describeRemaining(items: readonly ChecklistItem[]): string;
26
26
  export declare const newGoal: (objective: string, verify?: string) => GoalRecord;
27
+ /** The goal of the conversation as the log says it is now: the last goal entry on the branch. */
28
+ export declare function currentGoal(branch: readonly LogEntry[]): GoalRecord | undefined;
29
+ /** Starts a goal: the record, and a pinned message that tells the model about it. A goal that was there before is replaced. The one place that writes a new goal. */
30
+ export declare function startGoal(log: Log, goal: {
31
+ objective: string;
32
+ verify?: string;
33
+ }, statement: string): Promise<void>;
34
+ /** Ends the goal by hand: it counts as complete and the model is no longer told about it. Does nothing when there is no goal or it has ended. */
35
+ export declare function endGoal(log: Log): Promise<void>;
@@ -35,3 +35,31 @@ export const newGoal = (objective, verify) => ({
35
35
  status: "active",
36
36
  humanStreak: 0,
37
37
  });
38
+ /** The goal of the conversation as the log says it is now: the last goal entry on the branch. */
39
+ export function currentGoal(branch) {
40
+ const entry = [...branch].reverse().find((candidate) => candidate.type === "goal");
41
+ return entry?.type === "goal" ? entry.goal : undefined;
42
+ }
43
+ /** Takes the earlier "your goal is ..." messages out of the context, so the model is told about one goal only. */
44
+ async function withdrawGoalMessages(log) {
45
+ const branch = await log.branch();
46
+ const withdrawn = new Set(branch.flatMap((entry) => (entry.type === "context_edit" ? [entry.targetId] : [])));
47
+ for (const entry of branch) {
48
+ if (entry.type === "custom_message" && entry.customType === "goal" && !withdrawn.has(entry.id))
49
+ await log.append({ type: "context_edit", targetId: entry.id, replacement: null });
50
+ }
51
+ }
52
+ /** Starts a goal: the record, and a pinned message that tells the model about it. A goal that was there before is replaced. The one place that writes a new goal. */
53
+ export async function startGoal(log, goal, statement) {
54
+ await withdrawGoalMessages(log);
55
+ await log.append({ type: "goal", goal: newGoal(goal.objective, goal.verify) });
56
+ await log.append({ type: "custom_message", customType: "goal", pinned: true, content: [{ type: "text", text: statement }] });
57
+ }
58
+ /** Ends the goal by hand: it counts as complete and the model is no longer told about it. Does nothing when there is no goal or it has ended. */
59
+ export async function endGoal(log) {
60
+ const goal = currentGoal(await log.branch());
61
+ if (!goal || goal.status === "complete")
62
+ return;
63
+ await withdrawGoalMessages(log);
64
+ await log.append({ type: "goal", goal: { ...goal, status: "complete", humanStreak: 0 } });
65
+ }
@@ -1,5 +1,10 @@
1
1
  import type { Decision, Event, EventTransform, HookContext, LlmRequest, Message, Plugin, ProviderError, Tool, ToolCall, ToolResult, TurnInfo } from "./types.js";
2
2
  import type { PauseReason } from "./types.js";
3
+ /**
4
+ * A short account of why a check failed, for the model: the first line of the message, at most 200 characters, no stack. The whole error goes to the
5
+ * `plugin` event for the person running the agent; the model needs enough to choose what to do next and no more.
6
+ */
7
+ export declare function describeFailure(error: unknown): string;
3
8
  export type ReportFailure = (plugin: string, hook: string, error: unknown) => void;
4
9
  export interface TurnEndOutcome {
5
10
  append: Message[];
@@ -1,4 +1,18 @@
1
1
  import { FatalHookError } from "./types.js";
2
+ /**
3
+ * A short account of why a check failed, for the model: the first line of the message, at most 200 characters, no stack. The whole error goes to the
4
+ * `plugin` event for the person running the agent; the model needs enough to choose what to do next and no more.
5
+ */
6
+ export function describeFailure(error) {
7
+ const message = error instanceof Error ? error.message : String(error);
8
+ const line = message
9
+ .split("\n")
10
+ .find((candidate) => candidate.trim() !== "")
11
+ ?.trim();
12
+ if (line === undefined)
13
+ return error instanceof Error ? error.name : "unknown error";
14
+ return line.length > 200 ? `${line.slice(0, 200)}…` : line;
15
+ }
2
16
  export function createHookSet(plugins, builtinTools, report, emitFromPlugin) {
3
17
  const registry = {
4
18
  context: [],
@@ -77,7 +91,13 @@ export function createHookSet(plugins, builtinTools, report, emitFromPlugin) {
77
91
  }
78
92
  catch (error) {
79
93
  report(plugin, "tool.before", error);
80
- return { decision: { kind: "deny", reason: `The check by '${plugin}' failed, so the call was not allowed.` }, args };
94
+ return {
95
+ decision: {
96
+ kind: "deny",
97
+ reason: `The check by '${plugin}' failed (${describeFailure(error)}), so the call was not allowed. Do not simply repeat the call; choose another approach, or tell the user what you need.`,
98
+ },
99
+ args,
100
+ };
81
101
  }
82
102
  }
83
103
  return { decision: { kind: "allow" }, args };
@@ -1,6 +1,7 @@
1
1
  export * from "./types.js";
2
2
  export { COMPACTION_PREFIX } from "./constants.js";
3
3
  export { createKernel } from "./loop.js";
4
- export type { Kernel, KernelConfig, RunHandle, RunResult, UserInput } from "./loop.js";
5
- export type { CompactionConfig } from "./compaction.js";
4
+ export { describeFailure } from "./hooks.js";
5
+ export type { Kernel, KernelConfig, RunHandle, RunOptions, RunResult, UserInput } from "./loop.js";
6
+ export type { CompactionConfig, ContextStatus } from "./compaction.js";
6
7
  export type { Prompts } from "./prompts.js";
@@ -1,3 +1,4 @@
1
1
  export * from "./types.js";
2
2
  export { COMPACTION_PREFIX } from "./constants.js";
3
3
  export { createKernel } from "./loop.js";
4
+ export { describeFailure } from "./hooks.js";
@@ -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,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;