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.
- package/README.md +207 -77
- package/README.zh-CN.md +208 -78
- package/THIRD_PARTY_NOTICES.md +8 -58
- package/dist/packages/agent/src/index.d.ts +49 -11
- package/dist/packages/agent/src/index.js +50 -55
- package/dist/packages/agent/src/restrict-tools.d.ts +6 -0
- package/dist/packages/agent/src/restrict-tools.js +11 -0
- package/dist/packages/agent/src/system-prompt.d.ts +5 -0
- package/dist/packages/agent/src/system-prompt.js +1 -1
- package/dist/packages/agent/src/wake.d.ts +41 -0
- package/dist/packages/agent/src/wake.js +71 -0
- package/dist/packages/env/src/confine.d.ts +13 -0
- package/dist/packages/env/src/confine.js +40 -0
- package/dist/packages/env/src/connect-ssh.d.ts +45 -0
- package/dist/packages/env/src/connect-ssh.js +242 -0
- package/dist/packages/env/src/index.d.ts +6 -1
- package/dist/packages/env/src/index.js +3 -1
- package/dist/packages/env/src/local.d.ts +11 -2
- package/dist/packages/env/src/local.js +42 -10
- package/dist/packages/env/src/remote-stream.d.ts +48 -0
- package/dist/packages/env/src/remote-stream.js +143 -0
- package/dist/packages/env/src/remote-types.d.ts +17 -0
- package/dist/packages/env/src/remote-types.js +10 -0
- package/dist/packages/env/src/remote.d.ts +16 -7
- package/dist/packages/env/src/remote.js +73 -10
- package/dist/packages/evals/src/bfcl.d.ts +24 -0
- package/dist/packages/evals/src/bfcl.js +71 -0
- package/dist/packages/evals/src/index.d.ts +3 -0
- package/dist/packages/evals/src/index.js +2 -0
- package/dist/packages/evals/src/png.d.ts +2 -0
- package/dist/packages/evals/src/png.js +30 -0
- package/dist/packages/evals/src/pool.d.ts +3 -0
- package/dist/packages/evals/src/pool.js +22 -0
- package/dist/packages/evals/src/providers.d.ts +13 -0
- package/dist/packages/evals/src/providers.js +23 -0
- package/dist/packages/kernel/src/compaction.d.ts +24 -3
- package/dist/packages/kernel/src/compaction.js +23 -16
- package/dist/packages/kernel/src/goal.d.ts +10 -1
- package/dist/packages/kernel/src/goal.js +28 -0
- package/dist/packages/kernel/src/hooks.d.ts +5 -0
- package/dist/packages/kernel/src/hooks.js +21 -1
- package/dist/packages/kernel/src/index.d.ts +3 -2
- package/dist/packages/kernel/src/index.js +1 -0
- package/dist/packages/kernel/src/loop.d.ts +28 -4
- package/dist/packages/kernel/src/loop.js +69 -47
- package/dist/packages/kernel/src/types.d.ts +25 -4
- package/dist/packages/kernel/src/usage.d.ts +3 -0
- package/dist/packages/kernel/src/usage.js +16 -0
- package/dist/packages/llm/src/chat/errors.d.ts +11 -0
- package/dist/packages/llm/src/chat/errors.js +17 -0
- package/dist/packages/llm/src/chat/request.js +22 -4
- package/dist/packages/llm/src/chat/retry.d.ts +1 -1
- package/dist/packages/llm/src/chat/retry.js +1 -1
- package/dist/packages/llm/src/chat/stream.js +2 -1
- package/dist/packages/llm/src/index.js +7 -2
- package/dist/packages/plugins/context/src/index.d.ts +2 -1
- package/dist/packages/plugins/context/src/index.js +2 -1
- package/dist/packages/plugins/limits/src/index.d.ts +4 -1
- package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
- package/dist/packages/plugins/redaction/src/index.js +3 -0
- package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
- package/dist/packages/plugins/review/src/chain.d.ts +10 -1
- package/dist/packages/plugins/review/src/chain.js +7 -4
- package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
- package/dist/packages/plugins/review/src/hard-rules.js +58 -25
- package/dist/packages/plugins/review/src/index.d.ts +21 -0
- package/dist/packages/plugins/review/src/index.js +42 -7
- package/dist/packages/plugins/review/src/reviewer.d.ts +32 -3
- package/dist/packages/plugins/review/src/reviewer.js +115 -27
- package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
- package/dist/packages/plugins/tools/src/builtin/image.d.ts +11 -0
- package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
- package/dist/packages/plugins/tools/src/builtin/read.js +26 -12
- package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
- package/dist/packages/plugins/toolset/src/codemode.js +84 -26
- package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
- package/dist/packages/plugins/toolset/src/index.js +1 -1
- package/dist/packages/plugins/trace/src/index.d.ts +27 -0
- package/dist/packages/plugins/trace/src/index.js +29 -0
- package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
- package/dist/packages/plugins/trace/src/modules.js +28 -0
- package/dist/packages/session/src/file-log.d.ts +6 -1
- package/dist/packages/session/src/file-log.js +45 -25
- package/dist/packages/session/src/fork.d.ts +16 -0
- package/dist/packages/session/src/fork.js +70 -0
- package/dist/packages/session/src/index.d.ts +2 -0
- package/dist/packages/session/src/index.js +1 -0
- package/dist/packages/session/src/log-core.d.ts +37 -0
- package/dist/packages/session/src/log-core.js +47 -0
- package/dist/packages/session/src/memory-log.d.ts +3 -12
- package/dist/packages/session/src/memory-log.js +3 -23
- package/dist/packages/session/src/projection.js +22 -3
- package/dist/packages/testing/src/faux.d.ts +36 -0
- package/dist/packages/testing/src/faux.js +69 -0
- package/dist/packages/testing/src/index.d.ts +4 -0
- package/dist/packages/testing/src/index.js +2 -0
- package/dist/packages/testing/src/rng.d.ts +7 -0
- package/dist/packages/testing/src/rng.js +17 -0
- package/dist/sdk/index.d.ts +11 -3
- package/dist/sdk/index.js +5 -1
- package/dist/sdk/testing.d.ts +2 -0
- package/dist/sdk/testing.js +2 -0
- 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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
231
|
-
|
|
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,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
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 = [
|