@ruakit/agent 0.0.0-stage → 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +4 -0
- package/dist/approval.d.ts +17 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +3 -0
- package/dist/approval.js.map +7 -0
- package/dist/constants.d.ts +12 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +3 -0
- package/dist/constants.js.map +7 -0
- package/dist/decision-runtime.d.ts +5 -0
- package/dist/decision-runtime.d.ts.map +1 -0
- package/dist/decision-runtime.js +3 -0
- package/dist/decision-runtime.js.map +7 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +3 -0
- package/dist/errors.js.map +7 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +7 -0
- package/dist/operations.d.ts +10 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +3 -0
- package/dist/operations.js.map +7 -0
- package/dist/plan-transport.d.ts +11 -0
- package/dist/plan-transport.d.ts.map +1 -0
- package/dist/plan-transport.js +3 -0
- package/dist/plan-transport.js.map +7 -0
- package/dist/plan.d.ts +47 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +3 -0
- package/dist/plan.js.map +7 -0
- package/dist/request-outcome.d.ts +43 -0
- package/dist/request-outcome.d.ts.map +1 -0
- package/dist/request-outcome.js +3 -0
- package/dist/request-outcome.js.map +7 -0
- package/dist/request-state.d.ts +84 -0
- package/dist/request-state.d.ts.map +1 -0
- package/dist/request-state.js +3 -0
- package/dist/request-state.js.map +7 -0
- package/dist/runtime.d.ts +29 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +3 -0
- package/dist/runtime.js.map +7 -0
- package/dist/session.d.ts +49 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +3 -0
- package/dist/session.js.map +7 -0
- package/dist/transport.d.ts +11 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +3 -0
- package/dist/transport.js.map +7 -0
- package/dist/user-interruption.d.ts +6 -0
- package/dist/user-interruption.d.ts.map +1 -0
- package/dist/user-interruption.js +25 -0
- package/dist/user-interruption.js.map +1 -0
- package/package.json +32 -3
- package/src/approval.ts +47 -0
- package/src/constants.ts +13 -0
- package/src/decision-runtime.ts +26 -0
- package/src/errors.ts +56 -0
- package/src/index.ts +50 -0
- package/src/operations.ts +71 -0
- package/src/plan-transport.ts +82 -0
- package/src/plan.ts +468 -0
- package/src/request-outcome.ts +58 -0
- package/src/request-state.ts +189 -0
- package/src/runtime.ts +88 -0
- package/src/session.ts +408 -0
- package/src/transport.ts +52 -0
- package/README.md +0 -3
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
export interface RequestOperation {
|
|
4
|
+
signal: AbortSignal;
|
|
5
|
+
dispose(): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function validateRequestTimeout(timeoutMs: number): void {
|
|
9
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) {
|
|
10
|
+
throw new RangeError(
|
|
11
|
+
'RuaKit request timeout must be an integer from 1 to 2147483647 milliseconds.',
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Own the deadline and caller subscription until this request settles. */
|
|
17
|
+
export function createRequestOperation(
|
|
18
|
+
timeoutMs: number,
|
|
19
|
+
callerSignal?: AbortSignal,
|
|
20
|
+
): RequestOperation {
|
|
21
|
+
validateRequestTimeout(timeoutMs);
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const cancel = (): void => controller.abort(callerSignal?.reason);
|
|
24
|
+
callerSignal?.addEventListener('abort', cancel, { once: true });
|
|
25
|
+
if (callerSignal?.aborted) {
|
|
26
|
+
cancel();
|
|
27
|
+
}
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
|
|
30
|
+
}, timeoutMs);
|
|
31
|
+
return {
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
dispose() {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
callerSignal?.removeEventListener('abort', cancel);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Stop awaiting an adapter that ignores cancellation; its late result is discarded. */
|
|
41
|
+
export async function runWithSignal<Value>(
|
|
42
|
+
signal: AbortSignal,
|
|
43
|
+
operation: () => Value | Promise<Value>,
|
|
44
|
+
): Promise<Value> {
|
|
45
|
+
signal.throwIfAborted();
|
|
46
|
+
return new Promise<Value>((resolve, reject) => {
|
|
47
|
+
const cleanup = (): void => signal.removeEventListener('abort', cancel);
|
|
48
|
+
const cancel = (): void => {
|
|
49
|
+
cleanup();
|
|
50
|
+
reject(signal.reason);
|
|
51
|
+
};
|
|
52
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
53
|
+
// Attach both settlement handlers even after cancellation so a late rejection
|
|
54
|
+
// from a custom adapter cannot become an unhandled rejection.
|
|
55
|
+
Promise.resolve()
|
|
56
|
+
.then(() => {
|
|
57
|
+
signal.throwIfAborted();
|
|
58
|
+
return operation();
|
|
59
|
+
})
|
|
60
|
+
.then(
|
|
61
|
+
(value) => {
|
|
62
|
+
cleanup();
|
|
63
|
+
resolve(value);
|
|
64
|
+
},
|
|
65
|
+
(error: unknown) => {
|
|
66
|
+
cleanup();
|
|
67
|
+
reject(error);
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import { planResponseSchema, RUNTIME_META_NAME } from '@ruakit/schema';
|
|
3
|
+
import type { PlanRequest, PlanResponse } from '@ruakit/schema';
|
|
4
|
+
import { RuaKitError, parsePayload } from './errors.js';
|
|
5
|
+
import type { HttpTransportOptions } from './transport.js';
|
|
6
|
+
|
|
7
|
+
export interface PlanTransport {
|
|
8
|
+
plan(input: PlanRequest, signal: AbortSignal): Promise<PlanResponse>;
|
|
9
|
+
}
|
|
10
|
+
export interface PlanHttpTransportOptions extends HttpTransportOptions {
|
|
11
|
+
document?: Document;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The plan protocol shares the existing route, including the server-only proxy. */
|
|
15
|
+
export function createPlanTransport(
|
|
16
|
+
options: PlanHttpTransportOptions = {},
|
|
17
|
+
): PlanTransport {
|
|
18
|
+
let serverUrl = options.serverUrl;
|
|
19
|
+
if (serverUrl === undefined) {
|
|
20
|
+
const embedded = options.document?.querySelector<HTMLMetaElement>(
|
|
21
|
+
`meta[name="${RUNTIME_META_NAME}"]`,
|
|
22
|
+
)?.content;
|
|
23
|
+
if (embedded !== undefined) {
|
|
24
|
+
try {
|
|
25
|
+
const config: unknown = JSON.parse(embedded);
|
|
26
|
+
if (
|
|
27
|
+
typeof config !== 'object' ||
|
|
28
|
+
config === null ||
|
|
29
|
+
Array.isArray(config) ||
|
|
30
|
+
('serverUrl' in config && typeof config.serverUrl !== 'string')
|
|
31
|
+
)
|
|
32
|
+
throw new Error();
|
|
33
|
+
if ('serverUrl' in config) serverUrl = config.serverUrl as string;
|
|
34
|
+
} catch (cause) {
|
|
35
|
+
throw new RuaKitError(
|
|
36
|
+
'INVALID_REQUEST',
|
|
37
|
+
'The page has invalid RuaKit configuration.',
|
|
38
|
+
{ cause },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const base = (serverUrl ?? '').replace(/\/$/, '');
|
|
44
|
+
const fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
45
|
+
return {
|
|
46
|
+
async plan(input, signal) {
|
|
47
|
+
const response = await fetcher(`${base}/v1/plan`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
signal,
|
|
50
|
+
cache: 'no-store',
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
body: JSON.stringify(input),
|
|
53
|
+
});
|
|
54
|
+
let data: unknown;
|
|
55
|
+
try {
|
|
56
|
+
data = await response.json();
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
throw new RuaKitError(
|
|
59
|
+
'HTTP_ERROR',
|
|
60
|
+
'The server returned invalid JSON.',
|
|
61
|
+
{ cause },
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const message =
|
|
66
|
+
typeof data === 'object' &&
|
|
67
|
+
data !== null &&
|
|
68
|
+
'error' in data &&
|
|
69
|
+
typeof data.error === 'string'
|
|
70
|
+
? data.error
|
|
71
|
+
: `RuaKit request failed (${response.status}).`;
|
|
72
|
+
throw new RuaKitError('HTTP_ERROR', message);
|
|
73
|
+
}
|
|
74
|
+
return parsePayload(
|
|
75
|
+
planResponseSchema,
|
|
76
|
+
data,
|
|
77
|
+
'INVALID_PLAN',
|
|
78
|
+
'The server returned an invalid plan response.',
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import { REQUEST_MESSAGES } from './constants.js';
|
|
3
|
+
import {
|
|
4
|
+
evaluateCompletionChecks,
|
|
5
|
+
planSchema,
|
|
6
|
+
planRequestSchema,
|
|
7
|
+
planResponseSchema,
|
|
8
|
+
planApprovalSchema,
|
|
9
|
+
} from '@ruakit/schema';
|
|
10
|
+
import type {
|
|
11
|
+
Plan,
|
|
12
|
+
PlannedStep,
|
|
13
|
+
CompletedPlanStep,
|
|
14
|
+
Replan,
|
|
15
|
+
PlanRequest,
|
|
16
|
+
PlanResponse,
|
|
17
|
+
CompletionCheck,
|
|
18
|
+
ExecutionResult,
|
|
19
|
+
Decision,
|
|
20
|
+
ClarificationTurn,
|
|
21
|
+
PlanApproval,
|
|
22
|
+
} from '@ruakit/schema';
|
|
23
|
+
import { RuntimeError } from '@ruakit/runtime';
|
|
24
|
+
import type { PageState, Screenshot } from '@ruakit/runtime';
|
|
25
|
+
import {
|
|
26
|
+
ClarificationNeededError,
|
|
27
|
+
RuaKitError,
|
|
28
|
+
parsePayload,
|
|
29
|
+
} from './errors.js';
|
|
30
|
+
import { runWithSignal } from './operations.js';
|
|
31
|
+
import type { ExecutionOptions } from './session.js';
|
|
32
|
+
import { requestApproval } from './approval.js';
|
|
33
|
+
|
|
34
|
+
export type PlanPlanner = (
|
|
35
|
+
input: PlanRequest,
|
|
36
|
+
signal: AbortSignal,
|
|
37
|
+
) => Promise<unknown>;
|
|
38
|
+
export interface PlanExecutionOptions extends ExecutionOptions {
|
|
39
|
+
prompt?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface PlanTaskOptions extends ExecutionOptions {
|
|
42
|
+
context?: unknown;
|
|
43
|
+
clarifications?: ClarificationTurn[];
|
|
44
|
+
/** Maximum replacement plans after the initial plan. Defaults to 3. */
|
|
45
|
+
maxReplans?: number;
|
|
46
|
+
onPlan?: (plan: Plan) => void;
|
|
47
|
+
}
|
|
48
|
+
export type PlanExecutionResult =
|
|
49
|
+
| {
|
|
50
|
+
status: 'completed';
|
|
51
|
+
planId: string;
|
|
52
|
+
completedSteps: CompletedPlanStep[];
|
|
53
|
+
result: ExecutionResult;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
status: 'needs-replan';
|
|
57
|
+
planId: string;
|
|
58
|
+
completedSteps: CompletedPlanStep[];
|
|
59
|
+
replan: Replan;
|
|
60
|
+
snapshot: PageState;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** A task retains checks and completed actions while waiting for clarification. */
|
|
64
|
+
export interface PlanTask {
|
|
65
|
+
readonly canResume: boolean;
|
|
66
|
+
request(
|
|
67
|
+
prompt: string,
|
|
68
|
+
planner: PlanPlanner,
|
|
69
|
+
options?: PlanTaskOptions,
|
|
70
|
+
): Promise<ExecutionResult>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PlanEngine {
|
|
74
|
+
readonly platform: string;
|
|
75
|
+
snapshot(): PageState;
|
|
76
|
+
apply(
|
|
77
|
+
value: unknown,
|
|
78
|
+
source: PageState,
|
|
79
|
+
signal: AbortSignal,
|
|
80
|
+
options: ExecutionOptions,
|
|
81
|
+
verification: {
|
|
82
|
+
baseline: PageState;
|
|
83
|
+
checks: CompletionCheck[];
|
|
84
|
+
prompt: string | undefined;
|
|
85
|
+
},
|
|
86
|
+
): Promise<Decision>;
|
|
87
|
+
screenshot(signal: AbortSignal, timeoutMs: number): Promise<Screenshot>;
|
|
88
|
+
}
|
|
89
|
+
type Owner = <T>(
|
|
90
|
+
options: ExecutionOptions,
|
|
91
|
+
perform: (signal: AbortSignal) => Promise<T>,
|
|
92
|
+
) => Promise<T>;
|
|
93
|
+
interface Progress {
|
|
94
|
+
baseline: PageState;
|
|
95
|
+
checks: CompletionCheck[] | undefined;
|
|
96
|
+
completedSteps: CompletedPlanStep[];
|
|
97
|
+
events: string[];
|
|
98
|
+
effects: { planId: string; signature: string }[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function progress(engine: PlanEngine): Progress {
|
|
102
|
+
return {
|
|
103
|
+
baseline: structuredClone(engine.snapshot()),
|
|
104
|
+
checks: undefined,
|
|
105
|
+
completedSteps: [],
|
|
106
|
+
events: [],
|
|
107
|
+
effects: [],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function notify(callback: (() => void) | undefined): void {
|
|
111
|
+
try {
|
|
112
|
+
callback?.();
|
|
113
|
+
} catch {
|
|
114
|
+
/* Feedback cannot change execution. */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function signature(step: PlannedStep): string {
|
|
118
|
+
const { id: _id, message: _message, ...action } = step;
|
|
119
|
+
return JSON.stringify({ ...action, target: { ...action.target, id: null } });
|
|
120
|
+
}
|
|
121
|
+
function matches(step: PlannedStep, state: PageState): PageState['controls'] {
|
|
122
|
+
const locator = step.target;
|
|
123
|
+
return state.controls.filter(
|
|
124
|
+
(control) =>
|
|
125
|
+
(locator.id === null || control.target.id === locator.id) &&
|
|
126
|
+
control.role === locator.role &&
|
|
127
|
+
control.label === locator.label &&
|
|
128
|
+
(locator.context === null || control.context === locator.context) &&
|
|
129
|
+
(locator.group === null ||
|
|
130
|
+
control.groups?.some((group) => group.name === locator.group)),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function execute(
|
|
135
|
+
engine: PlanEngine,
|
|
136
|
+
plan: Plan,
|
|
137
|
+
state: Progress,
|
|
138
|
+
signal: AbortSignal,
|
|
139
|
+
options: PlanExecutionOptions,
|
|
140
|
+
approval?: PlanApproval,
|
|
141
|
+
): Promise<PlanExecutionResult> {
|
|
142
|
+
if (
|
|
143
|
+
state.checks &&
|
|
144
|
+
JSON.stringify(state.checks) !== JSON.stringify(plan.checks)
|
|
145
|
+
)
|
|
146
|
+
throw new RuaKitError(
|
|
147
|
+
'INVALID_PLAN',
|
|
148
|
+
'Replacement plans must preserve every completion check.',
|
|
149
|
+
);
|
|
150
|
+
state.checks ??= structuredClone(plan.checks);
|
|
151
|
+
const verification = {
|
|
152
|
+
baseline: state.baseline,
|
|
153
|
+
checks: state.checks,
|
|
154
|
+
prompt: options.prompt,
|
|
155
|
+
};
|
|
156
|
+
const incomplete = (
|
|
157
|
+
snapshot: PageState,
|
|
158
|
+
reason: Replan['reason'],
|
|
159
|
+
message: string,
|
|
160
|
+
failedStepId: string | null,
|
|
161
|
+
): PlanExecutionResult => ({
|
|
162
|
+
status: 'needs-replan',
|
|
163
|
+
planId: plan.id,
|
|
164
|
+
completedSteps: structuredClone(state.completedSteps),
|
|
165
|
+
snapshot,
|
|
166
|
+
replan: {
|
|
167
|
+
planId: plan.id,
|
|
168
|
+
completedSteps: structuredClone(state.completedSteps),
|
|
169
|
+
failedStepId,
|
|
170
|
+
reason,
|
|
171
|
+
message,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
const prior = [...state.completedSteps];
|
|
175
|
+
if (approval) {
|
|
176
|
+
const observed = engine.snapshot();
|
|
177
|
+
await requestApproval(approval, observed, signal, options, options.prompt);
|
|
178
|
+
const current = engine.snapshot();
|
|
179
|
+
// Approval cannot silently move to a changed control while the user is reading.
|
|
180
|
+
if (
|
|
181
|
+
current.url !== observed.url ||
|
|
182
|
+
plan.steps.some((step) => {
|
|
183
|
+
const before = matches(step, observed);
|
|
184
|
+
// A later step can reveal a control described by a semantic locator.
|
|
185
|
+
if (!before.length && step.target.id === null) return false;
|
|
186
|
+
const after = matches(step, current);
|
|
187
|
+
return (
|
|
188
|
+
before.length !== 1 || after.length !== 1 ||
|
|
189
|
+
before[0]!.target.id !== after[0]!.target.id ||
|
|
190
|
+
before[0]!.target.version !== after[0]!.target.version
|
|
191
|
+
);
|
|
192
|
+
})
|
|
193
|
+
) throw new RuaKitError(
|
|
194
|
+
'STALE_TARGET',
|
|
195
|
+
'The page changed while awaiting approval. Request approval again from a fresh observation.',
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
// Existing outcomes may already satisfy a request without dispatching an action.
|
|
199
|
+
if (
|
|
200
|
+
!evaluateCompletionChecks(state.checks, engine.snapshot(), state.baseline)
|
|
201
|
+
.passed
|
|
202
|
+
) {
|
|
203
|
+
for (const step of plan.steps) {
|
|
204
|
+
signal.throwIfAborted();
|
|
205
|
+
const previous = prior.find((entry) => entry.step.id === step.id);
|
|
206
|
+
if (previous) {
|
|
207
|
+
if (signature(previous.step) !== signature(step))
|
|
208
|
+
throw new RuaKitError(
|
|
209
|
+
'INVALID_PLAN',
|
|
210
|
+
'A completed step ID cannot describe a different action.',
|
|
211
|
+
);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
!['fill', 'select', 'check', 'focus'].includes(step.type) &&
|
|
216
|
+
prior.some((entry) => signature(entry.step) === signature(step))
|
|
217
|
+
)
|
|
218
|
+
throw new RuaKitError(
|
|
219
|
+
'INVALID_PLAN',
|
|
220
|
+
'A replacement plan cannot replay an already completed action.',
|
|
221
|
+
);
|
|
222
|
+
if (state.completedSteps.length >= 200)
|
|
223
|
+
throw new RuaKitError(
|
|
224
|
+
'REPLAN_LIMIT',
|
|
225
|
+
'The task reached its execution limit.',
|
|
226
|
+
);
|
|
227
|
+
notify(() => options.onPhaseChange?.('observing'));
|
|
228
|
+
const source = engine.snapshot();
|
|
229
|
+
const controls = matches(step, source);
|
|
230
|
+
if (controls.length !== 1 || !controls[0]!.enabled)
|
|
231
|
+
return incomplete(
|
|
232
|
+
source,
|
|
233
|
+
'target-unavailable',
|
|
234
|
+
'The next step has no unique enabled target on the current page.',
|
|
235
|
+
step.id,
|
|
236
|
+
);
|
|
237
|
+
const { id: _id, message, ...action } = step;
|
|
238
|
+
const effect = JSON.stringify({
|
|
239
|
+
...action,
|
|
240
|
+
target: controls[0]!.target.id,
|
|
241
|
+
});
|
|
242
|
+
if (
|
|
243
|
+
!['fill', 'select', 'check', 'focus'].includes(step.type) &&
|
|
244
|
+
state.effects.some(
|
|
245
|
+
(entry) => entry.planId !== plan.id && entry.signature === effect,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
throw new RuaKitError(
|
|
249
|
+
'INVALID_PLAN',
|
|
250
|
+
'A replacement plan cannot replay an already completed action.',
|
|
251
|
+
);
|
|
252
|
+
try {
|
|
253
|
+
await engine.apply(
|
|
254
|
+
{
|
|
255
|
+
type: 'act',
|
|
256
|
+
message,
|
|
257
|
+
step: { ...action, target: controls[0]!.target },
|
|
258
|
+
},
|
|
259
|
+
source,
|
|
260
|
+
signal,
|
|
261
|
+
options,
|
|
262
|
+
verification,
|
|
263
|
+
);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
// STALE_TARGET is raised before dispatch. Other failures can have effects;
|
|
266
|
+
// stop rather than automatically retrying an uncertain action.
|
|
267
|
+
if (error instanceof RuntimeError && error.code === 'STALE_TARGET')
|
|
268
|
+
return incomplete(
|
|
269
|
+
engine.snapshot(),
|
|
270
|
+
'target-stale',
|
|
271
|
+
error.message,
|
|
272
|
+
step.id,
|
|
273
|
+
);
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
state.completedSteps.push({
|
|
277
|
+
planId: plan.id,
|
|
278
|
+
step: structuredClone(step),
|
|
279
|
+
});
|
|
280
|
+
state.effects.push({ planId: plan.id, signature: effect });
|
|
281
|
+
state.events.push(message.slice(0, 500));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
signal.throwIfAborted();
|
|
285
|
+
const final = engine.snapshot();
|
|
286
|
+
if (!evaluateCompletionChecks(state.checks, final, state.baseline).passed)
|
|
287
|
+
return incomplete(
|
|
288
|
+
final,
|
|
289
|
+
'outcome-mismatch',
|
|
290
|
+
'The plan finished, but the expected outcomes are not all present.',
|
|
291
|
+
null,
|
|
292
|
+
);
|
|
293
|
+
await engine.apply(
|
|
294
|
+
{ type: 'complete', message: plan.message, checks: state.checks },
|
|
295
|
+
final,
|
|
296
|
+
signal,
|
|
297
|
+
options,
|
|
298
|
+
verification,
|
|
299
|
+
);
|
|
300
|
+
return {
|
|
301
|
+
status: 'completed',
|
|
302
|
+
planId: plan.id,
|
|
303
|
+
completedSteps: structuredClone(state.completedSteps),
|
|
304
|
+
result: {
|
|
305
|
+
planId: plan.id,
|
|
306
|
+
status: 'triggered',
|
|
307
|
+
goalCompleted: true,
|
|
308
|
+
message: plan.message,
|
|
309
|
+
events: [...state.events],
|
|
310
|
+
verification: {
|
|
311
|
+
level: options.verifyCompletion ? 'confirmed' : 'observed',
|
|
312
|
+
checks: structuredClone(state.checks),
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Execute one supplied plan. This function never contacts a planner. */
|
|
319
|
+
export async function executePlan(
|
|
320
|
+
engine: PlanEngine,
|
|
321
|
+
value: unknown,
|
|
322
|
+
signal: AbortSignal,
|
|
323
|
+
options: PlanExecutionOptions,
|
|
324
|
+
): Promise<PlanExecutionResult> {
|
|
325
|
+
const approval = planApprovalSchema.safeParse(value);
|
|
326
|
+
const plan = approval.success
|
|
327
|
+
? approval.data.plan
|
|
328
|
+
: parsePayload(
|
|
329
|
+
planSchema,
|
|
330
|
+
value,
|
|
331
|
+
'INVALID_PLAN',
|
|
332
|
+
'Expected a valid plan or approval with an ID, steps and completion checks.',
|
|
333
|
+
);
|
|
334
|
+
return execute(
|
|
335
|
+
engine, plan, progress(engine), signal, options,
|
|
336
|
+
approval.success ? approval.data : undefined,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function createPlanTask(engine: PlanEngine, own: Owner): PlanTask {
|
|
341
|
+
let state: Progress | undefined;
|
|
342
|
+
let originalPrompt: string | undefined;
|
|
343
|
+
let replan: Replan | null = null;
|
|
344
|
+
let image: Screenshot | undefined;
|
|
345
|
+
let replacements = 0;
|
|
346
|
+
let finished = false;
|
|
347
|
+
const ids = new Set<string>();
|
|
348
|
+
return {
|
|
349
|
+
get canResume() {
|
|
350
|
+
return !finished;
|
|
351
|
+
},
|
|
352
|
+
request: (prompt, planner, options = {}) =>
|
|
353
|
+
own(options, async (signal) => {
|
|
354
|
+
if (finished)
|
|
355
|
+
throw new RuaKitError(
|
|
356
|
+
'INVALID_REQUEST',
|
|
357
|
+
'This plan task has already settled.',
|
|
358
|
+
);
|
|
359
|
+
const limit = options.maxReplans ?? 3;
|
|
360
|
+
if (!Number.isInteger(limit) || limit < 0 || limit > 10)
|
|
361
|
+
throw new RuaKitError(
|
|
362
|
+
'INVALID_REQUEST',
|
|
363
|
+
'maxReplans must be an integer between 0 and 10.',
|
|
364
|
+
);
|
|
365
|
+
if (originalPrompt !== undefined && originalPrompt !== prompt)
|
|
366
|
+
throw new RuaKitError(
|
|
367
|
+
'INVALID_REQUEST',
|
|
368
|
+
'Resume a plan task with its original prompt.',
|
|
369
|
+
);
|
|
370
|
+
originalPrompt = prompt;
|
|
371
|
+
state ??= progress(engine);
|
|
372
|
+
while (true) {
|
|
373
|
+
signal.throwIfAborted();
|
|
374
|
+
notify(() => options.onPhaseChange?.('observing'));
|
|
375
|
+
const source = engine.snapshot();
|
|
376
|
+
if (image && image.url !== source.url) image = undefined;
|
|
377
|
+
const input = parsePayload(
|
|
378
|
+
planRequestSchema,
|
|
379
|
+
{
|
|
380
|
+
protocol: 'plan',
|
|
381
|
+
prompt,
|
|
382
|
+
snapshot: source,
|
|
383
|
+
platform: engine.platform,
|
|
384
|
+
clarifications: options.clarifications ?? [],
|
|
385
|
+
checks: state.checks ?? null,
|
|
386
|
+
replan,
|
|
387
|
+
...(options.context === undefined
|
|
388
|
+
? {}
|
|
389
|
+
: { context: options.context }),
|
|
390
|
+
canScreenshot: true,
|
|
391
|
+
...(image ? { screenshot: image } : {}),
|
|
392
|
+
},
|
|
393
|
+
'INVALID_REQUEST',
|
|
394
|
+
'Expected a valid planning request and bounded JSON context.',
|
|
395
|
+
);
|
|
396
|
+
notify(() => options.onPhaseChange?.('planning'));
|
|
397
|
+
const response: PlanResponse = parsePayload(
|
|
398
|
+
planResponseSchema,
|
|
399
|
+
await runWithSignal(signal, () => planner(input, signal)),
|
|
400
|
+
'INVALID_PLAN',
|
|
401
|
+
'The backend returned an invalid plan response.',
|
|
402
|
+
);
|
|
403
|
+
const approval = 'type' in response && response.type === 'approval'
|
|
404
|
+
? response : undefined;
|
|
405
|
+
if (!('id' in response) && response.type !== 'approval') {
|
|
406
|
+
if (response.type === 'clarify')
|
|
407
|
+
throw new ClarificationNeededError({
|
|
408
|
+
type: 'clarification',
|
|
409
|
+
question: response.message,
|
|
410
|
+
options: [],
|
|
411
|
+
});
|
|
412
|
+
if (response.type === 'unsupported')
|
|
413
|
+
throw new RuaKitError('UNAVAILABLE', response.message);
|
|
414
|
+
if (image)
|
|
415
|
+
throw new RuaKitError(
|
|
416
|
+
'INVALID_PLAN',
|
|
417
|
+
'Use the supplied screenshot before requesting another image.',
|
|
418
|
+
);
|
|
419
|
+
image = await engine.screenshot(signal, options.timeoutMs ?? 8000);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const plan = 'id' in response ? response : response.plan;
|
|
423
|
+
if (
|
|
424
|
+
ids.has(plan.id) ||
|
|
425
|
+
plan.replacesPlanId !== replan?.planId
|
|
426
|
+
)
|
|
427
|
+
throw new RuaKitError(
|
|
428
|
+
'INVALID_PLAN',
|
|
429
|
+
'Each plan needs a new ID and the correct replacement plan ID.',
|
|
430
|
+
);
|
|
431
|
+
if (replan && ++replacements > limit)
|
|
432
|
+
throw new RuaKitError(
|
|
433
|
+
'REPLAN_LIMIT',
|
|
434
|
+
'The task reached its replanning limit.',
|
|
435
|
+
);
|
|
436
|
+
ids.add(plan.id);
|
|
437
|
+
notify(() => options.onPlan?.(structuredClone(plan)));
|
|
438
|
+
let result: PlanExecutionResult;
|
|
439
|
+
try {
|
|
440
|
+
result = await execute(
|
|
441
|
+
engine, plan, state, signal, { ...options, prompt }, approval,
|
|
442
|
+
);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
finished = true;
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
image = undefined;
|
|
448
|
+
if (result.status === 'completed') {
|
|
449
|
+
finished = true;
|
|
450
|
+
return result.result;
|
|
451
|
+
}
|
|
452
|
+
replan = result.replan;
|
|
453
|
+
if (replacements >= limit) {
|
|
454
|
+
finished = true;
|
|
455
|
+
throw new RuaKitError(
|
|
456
|
+
'REPLAN_LIMIT',
|
|
457
|
+
'The task reached its replanning limit.',
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
notify(() =>
|
|
461
|
+
options.onProgress?.(
|
|
462
|
+
REQUEST_MESSAGES.replanning,
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
}),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import type { Clarification, ExecutionResult } from '@ruakit/schema';
|
|
3
|
+
import { RuntimeError } from '@ruakit/runtime';
|
|
4
|
+
import type { RuntimeErrorCode as BrowserErrorCode } from '@ruakit/runtime';
|
|
5
|
+
import { RuaKitError } from './errors.js';
|
|
6
|
+
import type { RuntimeErrorCode } from './errors.js';
|
|
7
|
+
|
|
8
|
+
/** Host labels for associating local work with a conversation. Never authorize actions. */
|
|
9
|
+
export interface RequestCorrelation {
|
|
10
|
+
readonly threadId?: string;
|
|
11
|
+
readonly messageId?: string;
|
|
12
|
+
readonly toolCallId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RequestIdentity {
|
|
16
|
+
/** Stable across clarification attempts; distinct from the UI's numeric attempt ID. */
|
|
17
|
+
readonly operationId: string;
|
|
18
|
+
readonly correlation?: RequestCorrelation;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RequestError {
|
|
22
|
+
readonly code:
|
|
23
|
+
RuntimeErrorCode | BrowserErrorCode | 'STALE_REQUEST' | 'REQUEST_FAILED';
|
|
24
|
+
readonly message: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Local integration outcomes, not additions to the planner wire protocol. */
|
|
28
|
+
export type RequestOutcome = RequestIdentity &
|
|
29
|
+
(
|
|
30
|
+
| { status: 'completed'; result: ExecutionResult }
|
|
31
|
+
| { status: 'triggered'; result: ExecutionResult }
|
|
32
|
+
| { status: 'needs-input'; clarification: Clarification }
|
|
33
|
+
| {
|
|
34
|
+
status: 'cancelled';
|
|
35
|
+
reason: 'caller' | 'explicit' | 'unmounted' | 'aborted';
|
|
36
|
+
}
|
|
37
|
+
| { status: 'rejected'; error: RequestError }
|
|
38
|
+
| {
|
|
39
|
+
status: 'failed';
|
|
40
|
+
error: RequestError;
|
|
41
|
+
/** A failed answer can be retried against the same pending question. */
|
|
42
|
+
canRespond: boolean;
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/** Preserve known execution codes without trusting arbitrary thrown objects. */
|
|
47
|
+
export function toRequestError(error: unknown): RequestError {
|
|
48
|
+
const exception = error instanceof Error || error instanceof DOMException;
|
|
49
|
+
return {
|
|
50
|
+
code:
|
|
51
|
+
error instanceof RuaKitError || error instanceof RuntimeError
|
|
52
|
+
? error.code
|
|
53
|
+
: exception && error.name === 'TimeoutError'
|
|
54
|
+
? 'TIMEOUT'
|
|
55
|
+
: 'REQUEST_FAILED',
|
|
56
|
+
message: exception ? error.message : 'Request failed.',
|
|
57
|
+
};
|
|
58
|
+
}
|