@itmetsam/samurai-framework 0.22.3 → 0.22.5

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 (99) hide show
  1. package/AGENT-GUIDE.md +50 -0
  2. package/bin/samurai.mjs +2 -0
  3. package/dist/agent/generate-kind.d.ts +5 -0
  4. package/dist/agent/generate-kind.js +40 -0
  5. package/dist/agent/index.d.ts +19 -0
  6. package/dist/agent/index.js +23 -0
  7. package/dist/agent/instructions.d.ts +13 -0
  8. package/dist/agent/instructions.js +44 -0
  9. package/dist/agent/limits.d.ts +12 -0
  10. package/dist/agent/limits.js +18 -0
  11. package/dist/agent/loop.d.ts +32 -0
  12. package/dist/agent/loop.js +46 -0
  13. package/dist/agent/model.d.ts +9 -0
  14. package/dist/agent/model.js +14 -0
  15. package/dist/agent/plan/guard.d.ts +7 -0
  16. package/dist/agent/plan/guard.js +32 -0
  17. package/dist/agent/plan/plan-file.d.ts +59 -0
  18. package/dist/agent/plan/plan-file.js +201 -0
  19. package/dist/agent/plan/plan-kind.d.ts +5 -0
  20. package/dist/agent/plan/plan-kind.js +28 -0
  21. package/dist/agent/plan/prompt.d.ts +3 -0
  22. package/dist/agent/plan/prompt.js +37 -0
  23. package/dist/agent/plan/read-plan.d.ts +5 -0
  24. package/dist/agent/plan/read-plan.js +28 -0
  25. package/dist/agent/plan/recorder.d.ts +30 -0
  26. package/dist/agent/plan/recorder.js +60 -0
  27. package/dist/agent/plan/run.d.ts +8 -0
  28. package/dist/agent/plan/run.js +10 -0
  29. package/dist/agent/plan/tools.d.ts +4 -0
  30. package/dist/agent/plan/tools.js +95 -0
  31. package/dist/agent/prompt.d.ts +21 -0
  32. package/dist/agent/prompt.js +60 -0
  33. package/dist/agent/providers.d.ts +19 -0
  34. package/dist/agent/providers.js +95 -0
  35. package/dist/agent/recording.d.ts +45 -0
  36. package/dist/agent/recording.js +78 -0
  37. package/dist/agent/run-kind.d.ts +48 -0
  38. package/dist/agent/run-kind.js +1 -0
  39. package/dist/agent/run.d.ts +15 -0
  40. package/dist/agent/run.js +201 -0
  41. package/dist/agent/sdk-tools.d.ts +15 -0
  42. package/dist/agent/sdk-tools.js +52 -0
  43. package/dist/agent/session.d.ts +62 -0
  44. package/dist/agent/session.js +196 -0
  45. package/dist/agent/snapshot-script.d.ts +18 -0
  46. package/dist/agent/snapshot-script.js +163 -0
  47. package/dist/agent/snapshot.d.ts +54 -0
  48. package/dist/agent/snapshot.js +95 -0
  49. package/dist/agent/take-over.d.ts +19 -0
  50. package/dist/agent/take-over.js +49 -0
  51. package/dist/agent/tools/act.d.ts +2 -0
  52. package/dist/agent/tools/act.js +124 -0
  53. package/dist/agent/tools/ask.d.ts +1 -0
  54. package/dist/agent/tools/ask.js +19 -0
  55. package/dist/agent/tools/context.d.ts +70 -0
  56. package/dist/agent/tools/context.js +42 -0
  57. package/dist/agent/tools/expect.d.ts +1 -0
  58. package/dist/agent/tools/expect.js +152 -0
  59. package/dist/agent/tools/finish.d.ts +1 -0
  60. package/dist/agent/tools/finish.js +12 -0
  61. package/dist/agent/tools/index.d.ts +3 -0
  62. package/dist/agent/tools/index.js +23 -0
  63. package/dist/agent/tools/navigate.d.ts +1 -0
  64. package/dist/agent/tools/navigate.js +53 -0
  65. package/dist/agent/tools/origin-guard.d.ts +6 -0
  66. package/dist/agent/tools/origin-guard.js +19 -0
  67. package/dist/agent/tools/origins.d.ts +11 -0
  68. package/dist/agent/tools/origins.js +22 -0
  69. package/dist/agent/tools/screenshot.d.ts +1 -0
  70. package/dist/agent/tools/screenshot.js +39 -0
  71. package/dist/agent/tools/snapshot.d.ts +1 -0
  72. package/dist/agent/tools/snapshot.js +12 -0
  73. package/dist/agent/tools/undo.d.ts +1 -0
  74. package/dist/agent/tools/undo.js +23 -0
  75. package/dist/agent/tools/use-secret.d.ts +1 -0
  76. package/dist/agent/tools/use-secret.js +41 -0
  77. package/dist/agent/types.d.ts +128 -0
  78. package/dist/agent/types.js +1 -0
  79. package/dist/capabilities.d.ts +2 -0
  80. package/dist/capabilities.js +2 -0
  81. package/dist/cli/agent-run.d.ts +43 -0
  82. package/dist/cli/agent-run.js +132 -0
  83. package/dist/cli/args.d.ts +8 -0
  84. package/dist/cli/args.js +8 -0
  85. package/dist/cli/generate.d.ts +6 -0
  86. package/dist/cli/generate.js +48 -0
  87. package/dist/cli/io.d.ts +2 -0
  88. package/dist/cli/main.js +6 -0
  89. package/dist/cli/plan.d.ts +6 -0
  90. package/dist/cli/plan.js +28 -0
  91. package/dist/cli/prompt.d.ts +2 -0
  92. package/dist/cli/prompt.js +16 -0
  93. package/dist/cli/usage.d.ts +1 -1
  94. package/dist/cli/usage.js +26 -1
  95. package/dist/index.js +2 -0
  96. package/dist/recorder/normalise.d.ts +6 -0
  97. package/dist/recorder/normalise.js +2 -0
  98. package/dist/types/config.d.ts +5 -0
  99. package/package.json +19 -4
@@ -0,0 +1,78 @@
1
+ import path from "node:path";
2
+ import { projectDir } from "../config/config.js";
3
+ import { Normaliser, } from "../recorder/normalise.js";
4
+ import { prepareSpec, readSpec, SpecWriter } from "../recorder/spec-source.js";
5
+ import { stepToSource } from "../steps/emit.js";
6
+ /** The statement an event wrote, `undefined` for a removal */
7
+ function codeOf(event) {
8
+ return event.op === "remove" ? undefined : stepToSource(event.step);
9
+ }
10
+ /** What a tool tells the model about the steps it wrote */
11
+ export function writtenText(events) {
12
+ const codes = events.flatMap((event) => codeOf(event) ?? []);
13
+ return codes.length > 0 ? ` Step written: ${codes.join(" ")}` : "";
14
+ }
15
+ /**
16
+ * Writes the agent's steps the way a recording does: an observation goes through the recorder's normaliser and
17
+ * `SpecWriter`, so the file is a valid spec after every call and holds only typed steps.
18
+ */
19
+ export class StepRecorder {
20
+ constructor(options) {
21
+ this.options = options;
22
+ this.file = path.resolve(projectDir(), options.file);
23
+ const spec = prepareSpec(this.file, readSpec(this.file, options), options);
24
+ this.testName = spec.testName;
25
+ this.writer = new SpecWriter(spec.source, this.file, spec.testIndex);
26
+ this.steps = spec.at;
27
+ this.first = spec.at;
28
+ this.normaliser = this.fresh();
29
+ }
30
+ fresh() {
31
+ return new Normaliser({
32
+ at: this.steps,
33
+ ...(this.options.baseURL !== undefined && {
34
+ baseURL: this.options.baseURL,
35
+ }),
36
+ });
37
+ }
38
+ /** Steps in the test now */
39
+ get count() {
40
+ return this.steps;
41
+ }
42
+ /** The events written so far */
43
+ get events() {
44
+ return this.writer.events;
45
+ }
46
+ write(event) {
47
+ const source = this.writer.write(event);
48
+ if (event.op === "insert")
49
+ this.steps++;
50
+ else if (event.op === "remove")
51
+ this.steps--;
52
+ this.options.onStep?.(event, codeOf(event), source);
53
+ }
54
+ /** Writes what `observation` makes of the spec and returns the events, which hold the statements */
55
+ observe(observation) {
56
+ const events = this.normaliser.observe(observation, Date.now());
57
+ for (const event of events)
58
+ this.write(event);
59
+ return events;
60
+ }
61
+ /** Takes out the last `count` steps (at most the ones written in this run) and returns how many went */
62
+ undo(count) {
63
+ const removed = Math.max(0, Math.min(count, this.steps - this.first));
64
+ for (let i = 0; i < removed; i++)
65
+ this.write({ op: "remove", index: this.steps - 1 });
66
+ if (removed > 0)
67
+ this.normaliser = this.fresh();
68
+ return removed;
69
+ }
70
+ /** Writes an event a person's recording produced while the agent was not acting (see `rebase`) */
71
+ external(event) {
72
+ this.write(event);
73
+ }
74
+ /** Starts the agent's own normaliser again after the person's steps: it carries on after the last one */
75
+ rebase() {
76
+ this.normaliser = this.fresh();
77
+ }
78
+ }
@@ -0,0 +1,48 @@
1
+ import type Page from "../browser/page.js";
2
+ import type { PromptInput } from "./prompt.js";
3
+ import type { PersonRecording } from "./take-over.js";
4
+ import type { Snapshotter } from "./snapshot.js";
5
+ import type { AgentTool, ToolContext, ToolSink } from "./tools/context.js";
6
+ import type { AgentEvent, BaseAgentOptions } from "./types.js";
7
+ /** What a run keeps of its work: the spec's steps, or the plan's. `AgentResult` is made from it */
8
+ export interface RunSink extends ToolSink {
9
+ /** Steps written so far */
10
+ readonly count: number;
11
+ /** The file the run writes */
12
+ readonly file: string;
13
+ /** Name of the test, or title of the plan */
14
+ readonly testName: string;
15
+ }
16
+ /** What the sink is made from */
17
+ interface SinkInput {
18
+ baseURL: string | undefined;
19
+ emit(event: AgentEvent): void;
20
+ }
21
+ /**
22
+ * What a run swaps for another kind of run: what it records, which tools it has, what the prompt says and what
23
+ * guards the page. The browser, the limits, the origin policy, the controller and the event stream stay the same.
24
+ */
25
+ export interface RunKind<O extends BaseAgentOptions = BaseAgentOptions, S extends RunSink = RunSink> {
26
+ /** The file the result names when no sink was made (the run failed before) */
27
+ file(options: O): string;
28
+ /** Makes the sink; throws a sentence when the file cannot be used. Runs before the browser opens */
29
+ sink(options: O, input: SinkInput): S;
30
+ /** The page is open on `url`: a first step may be recorded */
31
+ opened?(sink: S, url: string): void;
32
+ tools(context: ToolContext, sink: S): AgentTool[];
33
+ prompt(input: PromptInput, options: O): string;
34
+ /** The first message after the goal: the page as it is now, or that there is none */
35
+ opening(options: O, first: string): string;
36
+ /** Names the secrets the written steps read */
37
+ secrets(sink: S): string[];
38
+ /** Runs once the page is open, before the model's first call */
39
+ guard?(page: Page, sink: S): Promise<void>;
40
+ /** What the person's use of the browser does to the record */
41
+ person(input: {
42
+ page: Page;
43
+ sink: S;
44
+ snapshots: Snapshotter;
45
+ baseURL?: string;
46
+ }): PersonRecording;
47
+ }
48
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type Page from "../browser/page.js";
2
+ import type { RunKind, RunSink } from "./run-kind.js";
3
+ import { type AiModule } from "./sdk-tools.js";
4
+ import type { AgentOptions, AgentSession, BaseAgentOptions } from "./types.js";
5
+ /** What a run needs from outside, replaced in tests */
6
+ export interface RunDeps {
7
+ importAi?: () => Promise<AiModule>;
8
+ /** Called with the page once the browser is open (a test plays the person with it) */
9
+ onPage?: (page: Page) => void;
10
+ }
11
+ /**
12
+ * Starts a run; its work goes on in the background and `result` settles when it ends. The kind says what the run
13
+ * records, which tools it has and what guards the page; the default writes a spec.
14
+ */
15
+ export declare function startAgent<O extends BaseAgentOptions = AgentOptions, S extends RunSink = RunSink>(options: O, deps?: RunDeps, kind?: RunKind<O, S>): AgentSession;
@@ -0,0 +1,201 @@
1
+ import { loadRawConfig, projectDir } from "../config/config.js";
2
+ import { maskDeep, maskText } from "../config/mask.js";
3
+ import { launchRecordingBrowser, prepareRecordingBrowser, } from "../recorder/session-browser.js";
4
+ import { fixtureCatalog } from "../runner/fixture-registry.js";
5
+ import { withProject } from "../runner/project.js";
6
+ import { loadInstructions } from "./instructions.js";
7
+ import { resolveLimits } from "./limits.js";
8
+ import { runLoop } from "./loop.js";
9
+ import { assertModelObject } from "./model.js";
10
+ import { generateKind } from "./generate-kind.js";
11
+ import { toSdkTools } from "./sdk-tools.js";
12
+ import { AgentController } from "./session.js";
13
+ import { Snapshotter } from "./snapshot.js";
14
+ import { OriginPolicy } from "./tools/origins.js";
15
+ const ACTION_TIMEOUT = 5000;
16
+ async function loadAi(deps) {
17
+ try {
18
+ return await (deps.importAi ?? (() => import("ai")))();
19
+ }
20
+ catch (error) {
21
+ const missing = error.code;
22
+ throw new Error(missing === "ERR_MODULE_NOT_FOUND" || deps.importAi
23
+ ? 'agent(): the "ai" package is not installed. Install it, and a provider package for your model, with: npm install ai zod @ai-sdk/anthropic'
24
+ : `agent(): could not load the "ai" package: ${error.message}`);
25
+ }
26
+ }
27
+ /** The fixtures of the project's config, for the prompt; the agent works without them */
28
+ async function fixturesOf(options) {
29
+ try {
30
+ const config = options.config ?? (await loadRawConfig());
31
+ return fixtureCatalog(config.fixtures ?? []);
32
+ }
33
+ catch {
34
+ return [];
35
+ }
36
+ }
37
+ function endReason(controller, tally, limit, failed) {
38
+ const stopped = controller.stopReason;
39
+ if (stopped)
40
+ return stopped;
41
+ if (failed)
42
+ return "error";
43
+ if (tally.finished)
44
+ return "finished";
45
+ return limit ?? "finished";
46
+ }
47
+ /**
48
+ * Starts a run; its work goes on in the background and `result` settles when it ends. The kind says what the run
49
+ * records, which tools it has and what guards the page; the default writes a spec.
50
+ */
51
+ export function startAgent(options, deps = {}, kind = generateKind) {
52
+ assertModelObject(options.model);
53
+ const limits = resolveLimits(options.limits);
54
+ const person = {};
55
+ const controller = new AgentController({
56
+ begin: () => person.hooks?.begin() ?? Promise.resolve(),
57
+ end: () => person.hooks?.end() ?? Promise.resolve(""),
58
+ }, options.onEvent);
59
+ options.signal?.addEventListener("abort", () => controller.stop(), {
60
+ once: true,
61
+ });
62
+ if (options.signal?.aborted)
63
+ controller.stop();
64
+ const tally = { modelCalls: 0, tokens: 0, finished: false };
65
+ const done = { text: "" };
66
+ const work = async () => {
67
+ const ai = await loadAi(deps);
68
+ await withProject(options, async (run) => {
69
+ const { settings } = run;
70
+ const instructions = loadInstructions(projectDir());
71
+ const startUrl = options.url ?? settings.baseURL;
72
+ const prepared = await prepareRecordingBrowser({
73
+ ...(options.browser && { browser: options.browser }),
74
+ ...(options.port !== undefined && { port: options.port }),
75
+ signal: controller.signal,
76
+ });
77
+ const steps = kind.sink(options, {
78
+ baseURL: settings.baseURL,
79
+ emit: (event) => controller.emit(event),
80
+ });
81
+ done.steps = steps;
82
+ const { browser, page } = await launchRecordingBrowser(prepared, {
83
+ ...(options.port !== undefined && { port: options.port }),
84
+ signal: controller.signal,
85
+ headless: options.headless ?? false,
86
+ });
87
+ try {
88
+ deps.onPage?.(page);
89
+ if (options.url !== undefined)
90
+ await page.goto(options.url);
91
+ else if (settings.baseURL !== undefined)
92
+ await page.navigateTo(settings.baseURL);
93
+ const current = await page.url();
94
+ const onWeb = /^https?:/.test(current);
95
+ const snapshots = new Snapshotter(page, {
96
+ secretFields: options.secretFields ?? [],
97
+ });
98
+ if (onWeb)
99
+ kind.opened?.(steps, current);
100
+ const origins = new OriginPolicy([
101
+ ...(options.allowedOrigins ?? []),
102
+ ...(onWeb ? [current] : startUrl ? [startUrl] : []),
103
+ ]);
104
+ person.hooks = kind.person({
105
+ page,
106
+ sink: steps,
107
+ snapshots,
108
+ ...(settings.baseURL !== undefined && { baseURL: settings.baseURL }),
109
+ });
110
+ const context = {
111
+ page,
112
+ snapshots,
113
+ steps,
114
+ origins,
115
+ secrets: {
116
+ allowed: options.secrets ?? [],
117
+ get: (name) => run.secrets.get(name),
118
+ },
119
+ vision: options.vision === true,
120
+ actionTimeout: ACTION_TIMEOUT,
121
+ ask: (question, choices) => controller.ask(question, choices),
122
+ finish: (summary) => {
123
+ done.summary = summary;
124
+ tally.finished = true;
125
+ },
126
+ emit: (event) => controller.emit(event),
127
+ secretOnScreen: false,
128
+ };
129
+ await kind.guard?.(page, steps);
130
+ const system = kind.prompt({
131
+ instructions: instructions.text,
132
+ fixtures: await fixturesOf(options),
133
+ secrets: options.secrets ?? [],
134
+ origins: origins.list(),
135
+ vision: options.vision === true,
136
+ }, options);
137
+ const first = onWeb
138
+ ? `The browser is open on ${current}. This is the page now:\n\n${maskText((await snapshots.take()).text)}`
139
+ : "No page is open yet: start with navigate.";
140
+ const tools = toSdkTools(ai, kind.tools(context, steps), {
141
+ wait: () => controller.gate(),
142
+ note: async () => controller.takeNote(),
143
+ });
144
+ const outcome = await runLoop({
145
+ ai,
146
+ model: options.model,
147
+ system,
148
+ prompt: kind.opening(options, first),
149
+ tools,
150
+ controller,
151
+ limits,
152
+ tally,
153
+ });
154
+ done.text = outcome.text;
155
+ if (outcome.limit)
156
+ done.limit = outcome.limit;
157
+ }
158
+ finally {
159
+ await person.hooks?.dispose().catch(() => undefined);
160
+ await browser.close().catch(() => undefined);
161
+ }
162
+ });
163
+ };
164
+ const stopTimer = controller.watchTime(limits.timeoutMs);
165
+ const result = work()
166
+ .then(() => false)
167
+ .catch((error) => {
168
+ done.error = maskText(error instanceof Error ? error.message : String(error));
169
+ return true;
170
+ })
171
+ .then(async (failed) => {
172
+ stopTimer();
173
+ await controller.settle();
174
+ const steps = done.steps;
175
+ const reason = endReason(controller, tally, done.limit, failed);
176
+ const summary = done.summary ?? (done.text.trim() || undefined);
177
+ const ended = maskDeep({
178
+ reason,
179
+ ...(reason === "finished" && summary !== undefined && { summary }),
180
+ ...(reason === "error" &&
181
+ done.error !== undefined && { error: done.error }),
182
+ file: steps?.file ?? kind.file(options),
183
+ test: steps?.testName ?? "",
184
+ steps: steps?.count ?? 0,
185
+ secrets: steps ? kind.secrets(steps) : [],
186
+ tokens: tally.tokens,
187
+ });
188
+ controller.end(ended);
189
+ return ended;
190
+ });
191
+ return {
192
+ events: controller.events,
193
+ result,
194
+ answer: (id, text) => controller.answer(id, text),
195
+ pause: () => controller.pause(),
196
+ resume: () => controller.resume(),
197
+ takeOver: () => controller.takeOver(),
198
+ handBack: () => controller.handBack(),
199
+ stop: () => controller.stop(),
200
+ };
201
+ }
@@ -0,0 +1,15 @@
1
+ import type { AgentTool } from "./tools/context.js";
2
+ /** The AI SDK, loaded when a run starts: it is an optional peer dependency */
3
+ export type AiModule = typeof import("ai");
4
+ /** What stands between the model's call and the tool: waiting while paused, and the note after a hand back */
5
+ export interface ToolGate {
6
+ /** Resolves when the agent may act; rejects when the run ended */
7
+ wait(): Promise<void>;
8
+ /** Text to give the model instead of running its call, once, after the person used the browser */
9
+ note(): Promise<string | undefined>;
10
+ }
11
+ /**
12
+ * The tools in the AI SDK's shape. The SDK is given the JSON schema only: it would answer a wrong call with a
13
+ * dump of the validation library's issues, so the tools validate themselves and answer in a sentence.
14
+ */
15
+ export declare function toSdkTools(ai: AiModule, tools: readonly AgentTool[], gate: ToolGate): Record<string, unknown>;
@@ -0,0 +1,52 @@
1
+ function modelOutput(result) {
2
+ if (!result.ok)
3
+ return { type: "error-text", value: result.error };
4
+ if (!result.image)
5
+ return { type: "text", value: result.message };
6
+ return {
7
+ type: "content",
8
+ value: [
9
+ { type: "text", text: result.message },
10
+ {
11
+ type: "file",
12
+ data: { type: "data", data: result.image.data },
13
+ mediaType: result.image.mediaType,
14
+ },
15
+ ],
16
+ };
17
+ }
18
+ /**
19
+ * The tools in the AI SDK's shape. The SDK is given the JSON schema only: it would answer a wrong call with a
20
+ * dump of the validation library's issues, so the tools validate themselves and answer in a sentence.
21
+ */
22
+ export function toSdkTools(ai, tools, gate) {
23
+ // A model may call several tools in one step and the SDK runs them at once. They share one page and one spec,
24
+ // so they run one after the other, in the order the model made them
25
+ let queue = Promise.resolve();
26
+ const inOrder = (work) => {
27
+ const turn = queue.then(work, work);
28
+ queue = turn.catch(() => undefined);
29
+ return turn;
30
+ };
31
+ const entries = tools.map((agentTool) => {
32
+ const schema = ai.jsonSchema(() => ai.zodSchema(agentTool.inputSchema).jsonSchema, {
33
+ validate: (value) => ({ success: true, value }),
34
+ });
35
+ return [
36
+ agentTool.name,
37
+ ai.tool({
38
+ description: agentTool.description,
39
+ inputSchema: schema,
40
+ execute: (input) => inOrder(async () => {
41
+ await gate.wait();
42
+ const note = await gate.note();
43
+ if (note !== undefined)
44
+ return { ok: false, error: note };
45
+ return agentTool.run(input);
46
+ }),
47
+ toModelOutput: ({ output }) => modelOutput(output),
48
+ }),
49
+ ];
50
+ });
51
+ return Object.fromEntries(entries);
52
+ }
@@ -0,0 +1,62 @@
1
+ import type { AgentEvent, AgentResult } from "./types.js";
2
+ export type AbortReason = "stopped" | "time-limit" | "token-limit";
3
+ /** What taking the browser over starts and ends: the recording of what the person does */
4
+ export interface TakeOverHooks {
5
+ begin(): Promise<void>;
6
+ /** Ends the recording; the text tells the agent what the person did */
7
+ end(): Promise<string>;
8
+ }
9
+ /**
10
+ * The state of one run that the person controls: the event stream, pause and take over, the open questions, stop,
11
+ * and the active time. Time spent paused, taken over or waiting for an answer does not count against the time
12
+ * limit: it is the person's time, not the agent's.
13
+ */
14
+ export declare class AgentController {
15
+ private readonly hooks;
16
+ private readonly onEvent?;
17
+ private readonly log;
18
+ private readonly readers;
19
+ private closed;
20
+ private mode;
21
+ private transition;
22
+ private readonly gateWaiters;
23
+ private readonly aborter;
24
+ private abortReason;
25
+ private readonly questions;
26
+ private asked;
27
+ private note;
28
+ private readonly startedAt;
29
+ private idleSince;
30
+ private idleTotal;
31
+ constructor(hooks: TakeOverHooks, onEvent?: ((event: AgentEvent) => void) | undefined);
32
+ /** Aborts when the run is stopped or a limit is reached */
33
+ get signal(): AbortSignal;
34
+ get stopReason(): AbortReason | undefined;
35
+ get events(): AsyncIterable<AgentEvent>;
36
+ private read;
37
+ emit(event: AgentEvent): void;
38
+ /** Emits `ended` once; later calls change nothing */
39
+ end(result: AgentResult): void;
40
+ /** Time the agent has been working, in ms */
41
+ activeMs(now?: number): number;
42
+ private touch;
43
+ private wake;
44
+ /** Resolves when the agent may act; rejects when the run was stopped or hit a limit */
45
+ gate(): Promise<void>;
46
+ pause(): void;
47
+ resume(): void;
48
+ takeOver(): void;
49
+ handBack(): void;
50
+ /** The note for the agent about what the person did, once, after a hand back */
51
+ takeNote(): string | undefined;
52
+ /** Waits for a hand back that is still ending the person's recording (the run is ending anyway) */
53
+ settle(): Promise<void>;
54
+ /** Asks the person; waits without counting against the time limit. Rejects when the run stops */
55
+ ask(question: string, options?: string[]): Promise<string>;
56
+ answer(id: string, text: string): void;
57
+ /** Ends the run early: `stop()` from the person, or a limit */
58
+ abort(reason: AbortReason): void;
59
+ stop(): void;
60
+ /** Aborts with `time-limit` once the active time passes `timeoutMs`. Returns the function that stops the check */
61
+ watchTime(timeoutMs: number): () => void;
62
+ }
@@ -0,0 +1,196 @@
1
+ import { maskDeep } from "../config/mask.js";
2
+ /**
3
+ * The state of one run that the person controls: the event stream, pause and take over, the open questions, stop,
4
+ * and the active time. Time spent paused, taken over or waiting for an answer does not count against the time
5
+ * limit: it is the person's time, not the agent's.
6
+ */
7
+ export class AgentController {
8
+ constructor(hooks, onEvent) {
9
+ this.hooks = hooks;
10
+ this.onEvent = onEvent;
11
+ this.log = [];
12
+ this.readers = new Set();
13
+ this.closed = false;
14
+ this.mode = "running";
15
+ this.transition = Promise.resolve();
16
+ this.gateWaiters = new Set();
17
+ this.aborter = new AbortController();
18
+ this.questions = new Map();
19
+ this.asked = 0;
20
+ this.startedAt = Date.now();
21
+ this.idleTotal = 0;
22
+ }
23
+ /** Aborts when the run is stopped or a limit is reached */
24
+ get signal() {
25
+ return this.aborter.signal;
26
+ }
27
+ get stopReason() {
28
+ return this.abortReason;
29
+ }
30
+ get events() {
31
+ return { [Symbol.asyncIterator]: () => this.read() };
32
+ }
33
+ async *read() {
34
+ for (let index = 0;;) {
35
+ while (index < this.log.length)
36
+ yield this.log[index++];
37
+ if (this.closed)
38
+ return;
39
+ await new Promise((resolve) => {
40
+ const wake = () => {
41
+ this.readers.delete(wake);
42
+ resolve();
43
+ };
44
+ this.readers.add(wake);
45
+ });
46
+ }
47
+ }
48
+ emit(event) {
49
+ if (this.closed)
50
+ return;
51
+ const safe = maskDeep(event);
52
+ this.log.push(safe);
53
+ try {
54
+ this.onEvent?.(safe);
55
+ }
56
+ catch {
57
+ // a failing listener must not stop the run
58
+ }
59
+ for (const wake of [...this.readers])
60
+ wake();
61
+ }
62
+ /** Emits `ended` once; later calls change nothing */
63
+ end(result) {
64
+ if (this.closed)
65
+ return;
66
+ this.emit({ type: "ended", result });
67
+ this.closed = true;
68
+ for (const wake of [...this.readers])
69
+ wake();
70
+ }
71
+ /** Time the agent has been working, in ms */
72
+ activeMs(now = Date.now()) {
73
+ const idle = this.idleSince === undefined ? 0 : now - this.idleSince;
74
+ return now - this.startedAt - this.idleTotal - idle;
75
+ }
76
+ touch() {
77
+ const idle = this.mode !== "running" || this.questions.size > 0;
78
+ const now = Date.now();
79
+ if (idle && this.idleSince === undefined)
80
+ this.idleSince = now;
81
+ if (!idle && this.idleSince !== undefined) {
82
+ this.idleTotal += now - this.idleSince;
83
+ this.idleSince = undefined;
84
+ }
85
+ }
86
+ wake() {
87
+ for (const resume of [...this.gateWaiters])
88
+ resume();
89
+ this.gateWaiters.clear();
90
+ }
91
+ /** Resolves when the agent may act; rejects when the run was stopped or hit a limit */
92
+ async gate() {
93
+ for (;;) {
94
+ this.signal.throwIfAborted();
95
+ await this.transition;
96
+ this.signal.throwIfAborted();
97
+ if (this.mode === "running")
98
+ return;
99
+ await new Promise((resolve) => this.gateWaiters.add(resolve));
100
+ }
101
+ }
102
+ pause() {
103
+ if (this.mode !== "running")
104
+ return;
105
+ this.mode = "paused";
106
+ this.touch();
107
+ }
108
+ resume() {
109
+ if (this.mode !== "paused")
110
+ return;
111
+ this.mode = "running";
112
+ this.touch();
113
+ this.wake();
114
+ }
115
+ takeOver() {
116
+ if (this.mode === "taken-over")
117
+ return;
118
+ this.mode = "taken-over";
119
+ this.touch();
120
+ this.transition = this.transition
121
+ .then(() => this.hooks.begin())
122
+ .catch(() => undefined);
123
+ }
124
+ handBack() {
125
+ if (this.mode !== "taken-over")
126
+ return;
127
+ this.transition = this.transition
128
+ .then(async () => {
129
+ this.note = await this.hooks.end();
130
+ })
131
+ .catch(() => undefined)
132
+ .then(() => {
133
+ this.mode = "running";
134
+ this.touch();
135
+ this.wake();
136
+ });
137
+ }
138
+ /** The note for the agent about what the person did, once, after a hand back */
139
+ takeNote() {
140
+ const note = this.note;
141
+ this.note = undefined;
142
+ return note;
143
+ }
144
+ /** Waits for a hand back that is still ending the person's recording (the run is ending anyway) */
145
+ settle() {
146
+ return this.transition;
147
+ }
148
+ /** Asks the person; waits without counting against the time limit. Rejects when the run stops */
149
+ ask(question, options) {
150
+ const id = `q${++this.asked}`;
151
+ return new Promise((resolve, reject) => {
152
+ this.questions.set(id, {
153
+ answer: (text) => resolve(text),
154
+ fail: reject,
155
+ });
156
+ this.touch();
157
+ this.emit({
158
+ type: "question",
159
+ id,
160
+ question,
161
+ ...(options && options.length > 0 && { options }),
162
+ });
163
+ }).finally(() => {
164
+ this.questions.delete(id);
165
+ this.touch();
166
+ });
167
+ }
168
+ answer(id, text) {
169
+ const open = this.questions.get(id);
170
+ if (!open)
171
+ throw new Error(`agent(): there is no open question ${id}`);
172
+ open.answer(text);
173
+ }
174
+ /** Ends the run early: `stop()` from the person, or a limit */
175
+ abort(reason) {
176
+ if (this.abortReason)
177
+ return;
178
+ this.abortReason = reason;
179
+ this.aborter.abort(new Error(`agent run ended: ${reason}`));
180
+ for (const open of this.questions.values())
181
+ open.fail(new Error("agent run ended"));
182
+ this.wake();
183
+ }
184
+ stop() {
185
+ this.abort("stopped");
186
+ }
187
+ /** Aborts with `time-limit` once the active time passes `timeoutMs`. Returns the function that stops the check */
188
+ watchTime(timeoutMs) {
189
+ const timer = setInterval(() => {
190
+ if (this.activeMs() >= timeoutMs)
191
+ this.abort("time-limit");
192
+ }, Math.min(250, Math.max(10, timeoutMs / 4)));
193
+ timer.unref();
194
+ return () => clearInterval(timer);
195
+ }
196
+ }