@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,189 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import { REQUEST_MESSAGES } from './constants.js';
|
|
3
|
+
import type { Clarification, ExecutionResult } from "@ruakit/schema";
|
|
4
|
+
import type { RequestError, RequestIdentity } from "./request-outcome.js";
|
|
5
|
+
import type { ApprovalRequest } from "./approval.js";
|
|
6
|
+
|
|
7
|
+
/** Machine-readable execution stages; progress messages remain separate UI text. */
|
|
8
|
+
export type RequestPhase = "observing" | "planning" | "executing";
|
|
9
|
+
|
|
10
|
+
interface ActiveRequest {
|
|
11
|
+
requestId: number;
|
|
12
|
+
message: string;
|
|
13
|
+
identity?: RequestIdentity;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RequestState =
|
|
17
|
+
| { status: "idle"; message: string }
|
|
18
|
+
| (ActiveRequest & { status: "running" | "cancelling"; phase: RequestPhase })
|
|
19
|
+
| (ActiveRequest & {
|
|
20
|
+
status: "approval";
|
|
21
|
+
approvalId: string;
|
|
22
|
+
approval: ApprovalRequest;
|
|
23
|
+
originalPrompt: string;
|
|
24
|
+
})
|
|
25
|
+
| (ActiveRequest & { status: "triggered"; result: ExecutionResult })
|
|
26
|
+
| (ActiveRequest & {
|
|
27
|
+
status: "clarification";
|
|
28
|
+
clarification: Clarification;
|
|
29
|
+
originalPrompt: string;
|
|
30
|
+
})
|
|
31
|
+
| (ActiveRequest & { status: "cancelled" })
|
|
32
|
+
| (ActiveRequest & { status: "failed"; error?: RequestError });
|
|
33
|
+
|
|
34
|
+
export type RequestEvent =
|
|
35
|
+
| { type: "started"; requestId: number; identity?: RequestIdentity }
|
|
36
|
+
| { type: "progress"; requestId: number; message: string }
|
|
37
|
+
| { type: "phase"; requestId: number; phase: RequestPhase }
|
|
38
|
+
| {
|
|
39
|
+
type: "approval";
|
|
40
|
+
requestId: number;
|
|
41
|
+
approvalId: string;
|
|
42
|
+
approval: ApprovalRequest;
|
|
43
|
+
originalPrompt: string;
|
|
44
|
+
}
|
|
45
|
+
| { type: "approval-resolved"; requestId: number }
|
|
46
|
+
| {
|
|
47
|
+
type: "cancelling" | "cancelled";
|
|
48
|
+
requestId: number;
|
|
49
|
+
}
|
|
50
|
+
| { type: "triggered"; requestId: number; result: ExecutionResult }
|
|
51
|
+
| { type: "failed"; requestId: number; message: string; error?: RequestError }
|
|
52
|
+
| {
|
|
53
|
+
type: "clarification";
|
|
54
|
+
requestId: number;
|
|
55
|
+
clarification: Clarification;
|
|
56
|
+
originalPrompt: string;
|
|
57
|
+
message?: string;
|
|
58
|
+
}
|
|
59
|
+
| { type: "reset" };
|
|
60
|
+
|
|
61
|
+
export const INITIAL_REQUEST_STATE: RequestState = Object.freeze({
|
|
62
|
+
status: "idle",
|
|
63
|
+
message: REQUEST_MESSAGES.ready,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export function requestIsBusy(
|
|
67
|
+
state: RequestState,
|
|
68
|
+
): state is Extract<
|
|
69
|
+
RequestState,
|
|
70
|
+
{ status: "running" | "cancelling" | "approval" }
|
|
71
|
+
> {
|
|
72
|
+
return (
|
|
73
|
+
state.status === "running" ||
|
|
74
|
+
state.status === "cancelling" ||
|
|
75
|
+
state.status === "approval"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Ignore obsolete completions and progress after cancellation or settlement. */
|
|
80
|
+
export function requestReducer(
|
|
81
|
+
state: RequestState,
|
|
82
|
+
event: RequestEvent,
|
|
83
|
+
): RequestState {
|
|
84
|
+
if (event.type === "reset") {
|
|
85
|
+
return requestIsBusy(state) ? state : INITIAL_REQUEST_STATE;
|
|
86
|
+
}
|
|
87
|
+
if (event.type === "started") {
|
|
88
|
+
return requestIsBusy(state)
|
|
89
|
+
? state
|
|
90
|
+
: {
|
|
91
|
+
status: "running",
|
|
92
|
+
requestId: event.requestId,
|
|
93
|
+
...(event.identity ? { identity: event.identity } : {}),
|
|
94
|
+
phase: "observing",
|
|
95
|
+
message: REQUEST_MESSAGES.thinking,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const cancellingQuestion =
|
|
99
|
+
state.status === "clarification" && event.type === "cancelled";
|
|
100
|
+
if (cancellingQuestion && state.requestId === event.requestId) {
|
|
101
|
+
return {
|
|
102
|
+
status: "cancelled",
|
|
103
|
+
requestId: state.requestId,
|
|
104
|
+
...(state.identity ? { identity: state.identity } : {}),
|
|
105
|
+
message: REQUEST_MESSAGES.cancelled,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (!requestIsBusy(state) || state.requestId !== event.requestId) {
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
const identity = state.identity ? { identity: state.identity } : {};
|
|
112
|
+
switch (event.type) {
|
|
113
|
+
case "progress":
|
|
114
|
+
return state.status === "running"
|
|
115
|
+
? { ...state, message: event.message }
|
|
116
|
+
: state;
|
|
117
|
+
case "phase":
|
|
118
|
+
return state.status === "running"
|
|
119
|
+
? { ...state, phase: event.phase }
|
|
120
|
+
: state;
|
|
121
|
+
case "approval":
|
|
122
|
+
return state.status === "cancelling"
|
|
123
|
+
? state
|
|
124
|
+
: {
|
|
125
|
+
status: "approval",
|
|
126
|
+
...identity,
|
|
127
|
+
requestId: event.requestId,
|
|
128
|
+
approvalId: event.approvalId,
|
|
129
|
+
approval: event.approval,
|
|
130
|
+
originalPrompt: event.originalPrompt,
|
|
131
|
+
message: event.approval.message,
|
|
132
|
+
};
|
|
133
|
+
case "approval-resolved":
|
|
134
|
+
return state.status !== "approval"
|
|
135
|
+
? state
|
|
136
|
+
: {
|
|
137
|
+
status: "running",
|
|
138
|
+
...identity,
|
|
139
|
+
requestId: event.requestId,
|
|
140
|
+
phase: "executing",
|
|
141
|
+
message: REQUEST_MESSAGES.checkingApproval,
|
|
142
|
+
};
|
|
143
|
+
case "cancelling":
|
|
144
|
+
return {
|
|
145
|
+
...state,
|
|
146
|
+
status: "cancelling",
|
|
147
|
+
phase: state.status === "approval" ? "executing" : state.phase,
|
|
148
|
+
message: REQUEST_MESSAGES.cancelling,
|
|
149
|
+
};
|
|
150
|
+
case "cancelled":
|
|
151
|
+
return {
|
|
152
|
+
status: "cancelled",
|
|
153
|
+
...identity,
|
|
154
|
+
requestId: event.requestId,
|
|
155
|
+
message: REQUEST_MESSAGES.cancelled,
|
|
156
|
+
};
|
|
157
|
+
case "triggered":
|
|
158
|
+
return state.status === "cancelling"
|
|
159
|
+
? state
|
|
160
|
+
: {
|
|
161
|
+
status: "triggered",
|
|
162
|
+
...identity,
|
|
163
|
+
requestId: event.requestId,
|
|
164
|
+
result: event.result,
|
|
165
|
+
message: event.result.message ?? REQUEST_MESSAGES.triggered,
|
|
166
|
+
};
|
|
167
|
+
case "clarification":
|
|
168
|
+
return state.status === "cancelling"
|
|
169
|
+
? state
|
|
170
|
+
: {
|
|
171
|
+
status: "clarification",
|
|
172
|
+
...identity,
|
|
173
|
+
requestId: event.requestId,
|
|
174
|
+
clarification: event.clarification,
|
|
175
|
+
originalPrompt: event.originalPrompt,
|
|
176
|
+
message: event.message ?? event.clarification.question,
|
|
177
|
+
};
|
|
178
|
+
case "failed":
|
|
179
|
+
return state.status === "cancelling"
|
|
180
|
+
? state
|
|
181
|
+
: {
|
|
182
|
+
status: "failed",
|
|
183
|
+
...identity,
|
|
184
|
+
...(event.error ? { error: event.error } : {}),
|
|
185
|
+
requestId: event.requestId,
|
|
186
|
+
message: event.message,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import { RUNTIME_META_NAME } from '@ruakit/schema';
|
|
3
|
+
import type { ClarificationTurn, ExecutionResult } from '@ruakit/schema';
|
|
4
|
+
import { RuaKitError } from './errors.js';
|
|
5
|
+
import { createDecisionRuntime } from './decision-runtime.js';
|
|
6
|
+
import type { ExecutionOptions, RuaSession } from './session.js';
|
|
7
|
+
import { createHttpTransport } from './transport.js';
|
|
8
|
+
import { createPlanTransport } from './plan-transport.js';
|
|
9
|
+
import type { PlanTransport } from './plan-transport.js';
|
|
10
|
+
import type { PlanPlanner, PlanTask, PlanTaskOptions } from './plan.js';
|
|
11
|
+
import { createSession } from './session.js';
|
|
12
|
+
import type { HttpTransportOptions, RuaKitTransport } from './transport.js';
|
|
13
|
+
|
|
14
|
+
export interface RuntimeOptions extends HttpTransportOptions, Omit<ExecutionOptions, 'signal'> {
|
|
15
|
+
protocol?: 'decision';
|
|
16
|
+
document?: Document;
|
|
17
|
+
/** Reuse the target registry used by snapshot/run primitives. */
|
|
18
|
+
session?: RuaSession;
|
|
19
|
+
transport?: RuaKitTransport;
|
|
20
|
+
}
|
|
21
|
+
export interface PlanRuntimeOptions extends Omit<RuntimeOptions, 'transport' | 'protocol'>,
|
|
22
|
+
Pick<PlanTaskOptions, 'context' | 'maxReplans' | 'onPlan'> {
|
|
23
|
+
protocol: 'plan';
|
|
24
|
+
transport?: PlanTransport;
|
|
25
|
+
planner?: PlanPlanner;
|
|
26
|
+
task?: PlanTask;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RequestOptions {
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
clarifications?: ClarificationTurn[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RuaKitRuntime {
|
|
35
|
+
request(prompt: string, options?: RequestOptions): Promise<ExecutionResult>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Plan from the rendered page and execute only locally observed DOM targets. */
|
|
39
|
+
export function createRuntime(options?: RuntimeOptions): RuaKitRuntime;
|
|
40
|
+
export function createRuntime(options: PlanRuntimeOptions): RuaKitRuntime;
|
|
41
|
+
export function createRuntime(options: RuntimeOptions | PlanRuntimeOptions = {}): RuaKitRuntime {
|
|
42
|
+
const document = options.document ?? options.session?.document ?? globalThis.document;
|
|
43
|
+
if (!document?.defaultView) {
|
|
44
|
+
throw new RuaKitError('UNAVAILABLE', 'RuaKit requires a browser document.');
|
|
45
|
+
}
|
|
46
|
+
const embedded = document.querySelector<HTMLMetaElement>(
|
|
47
|
+
`meta[name="${RUNTIME_META_NAME}"]`,
|
|
48
|
+
)?.content;
|
|
49
|
+
let serverUrl: string | undefined;
|
|
50
|
+
if (embedded !== undefined) {
|
|
51
|
+
try {
|
|
52
|
+
const value: unknown = JSON.parse(embedded);
|
|
53
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
54
|
+
throw new Error();
|
|
55
|
+
}
|
|
56
|
+
const record = value as Record<string, unknown>;
|
|
57
|
+
if (
|
|
58
|
+
record.serverUrl !== undefined &&
|
|
59
|
+
(typeof record.serverUrl !== 'string' || !record.serverUrl.trim())
|
|
60
|
+
) {
|
|
61
|
+
throw new Error();
|
|
62
|
+
}
|
|
63
|
+
serverUrl = record.serverUrl as string | undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
throw new RuaKitError('INVALID_REQUEST', 'The page has invalid RuaKit configuration.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (options.protocol === 'plan') {
|
|
69
|
+
const session = options.session ?? createSession({ document });
|
|
70
|
+
if (session.document !== document)
|
|
71
|
+
throw new RuaKitError('INVALID_REQUEST', 'The Rua session belongs to a different document.');
|
|
72
|
+
const transport = options.planner ? undefined : options.transport ?? createPlanTransport({
|
|
73
|
+
...options, ...(options.serverUrl === undefined && serverUrl ? { serverUrl } : {}), document,
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
request: (prompt, settings = {}) => (options.task ?? session.createPlanTask()).request(prompt,
|
|
77
|
+
options.planner ?? ((input, signal) => transport!.plan(input, signal)), { ...options, ...settings }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return createDecisionRuntime(
|
|
81
|
+
options,
|
|
82
|
+
options.transport ??
|
|
83
|
+
createHttpTransport({
|
|
84
|
+
...options,
|
|
85
|
+
...(options.serverUrl === undefined && serverUrl ? { serverUrl } : {}),
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import { REQUEST_MESSAGES } from './constants.js';
|
|
3
|
+
import { createRuntime as createDomRuntime } from '@ruakit/runtime';
|
|
4
|
+
import type { ActionFeedbackOptions, PageState, Screenshot } from '@ruakit/runtime';
|
|
5
|
+
import {
|
|
6
|
+
completionChecksSchema,
|
|
7
|
+
evaluateCompletionChecks,
|
|
8
|
+
decisionSchema,
|
|
9
|
+
decisionRequestSchema,
|
|
10
|
+
} from '@ruakit/schema';
|
|
11
|
+
import type {
|
|
12
|
+
ClarificationTurn,
|
|
13
|
+
CompletionCheck,
|
|
14
|
+
ExecutionResult,
|
|
15
|
+
Decision,
|
|
16
|
+
} from '@ruakit/schema';
|
|
17
|
+
import { ClarificationNeededError, RuaKitError, parsePayload } from './errors.js';
|
|
18
|
+
import { createRequestOperation, runWithSignal, validateRequestTimeout } from './operations.js';
|
|
19
|
+
import type { RequestPhase } from './request-state.js';
|
|
20
|
+
import type { RuaKitTransport } from './transport.js';
|
|
21
|
+
import { executePlan, createPlanTask } from './plan.js';
|
|
22
|
+
import { requestApproval } from './approval.js';
|
|
23
|
+
import type { ApprovalOptions } from './approval.js';
|
|
24
|
+
import type { PlanExecutionOptions, PlanExecutionResult, PlanTask, PlanEngine } from './plan.js';
|
|
25
|
+
|
|
26
|
+
export interface ExecutionOptions extends ActionFeedbackOptions, ApprovalOptions {
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
requestTimeoutMs?: number;
|
|
30
|
+
/** Optional stronger application check, called only after all DOM outcomes pass. */
|
|
31
|
+
verifyCompletion?: (context: CompletionContext) => boolean | Promise<boolean>;
|
|
32
|
+
/** Observational feedback; exceptions cannot change execution. */
|
|
33
|
+
onProgress?: (message: string) => void;
|
|
34
|
+
onPhaseChange?: (phase: RequestPhase) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CompletionContext {
|
|
38
|
+
prompt: string | undefined;
|
|
39
|
+
checks: readonly CompletionCheck[];
|
|
40
|
+
baseline: PageState;
|
|
41
|
+
snapshot: PageState;
|
|
42
|
+
signal: AbortSignal;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RunOptions extends ExecutionOptions {
|
|
46
|
+
/** The original observation used to obtain this decision, from this session. */
|
|
47
|
+
snapshot: PageState;
|
|
48
|
+
/** Optional host-established outcomes for a manually owned loop. */
|
|
49
|
+
checks?: CompletionCheck[];
|
|
50
|
+
/** Original session observation for checks requiring a newly appeared result. */
|
|
51
|
+
baseline?: PageState;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PlanOptions extends ExecutionOptions {
|
|
55
|
+
clarifications?: ClarificationTurn[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RuaSession {
|
|
59
|
+
readonly document: Document;
|
|
60
|
+
snapshot(): PageState;
|
|
61
|
+
/** Validate and apply one decision. A triggered action does not establish task completion. */
|
|
62
|
+
run(decision: unknown, options: RunOptions): Promise<ExecutionResult>;
|
|
63
|
+
executePlan(plan: unknown, options?: PlanExecutionOptions): Promise<PlanExecutionResult>;
|
|
64
|
+
createPlanTask(): PlanTask;
|
|
65
|
+
/** Observe, request a decision, and apply it until the planner finishes or asks a question. */
|
|
66
|
+
plan(
|
|
67
|
+
prompt: string,
|
|
68
|
+
request: RuaKitTransport['dom'],
|
|
69
|
+
options?: PlanOptions,
|
|
70
|
+
): Promise<ExecutionResult>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One browser target registry shared by observation, execution, and planning. */
|
|
74
|
+
export function createSession(options: { document?: Document } = {}): RuaSession {
|
|
75
|
+
const document = options.document ?? globalThis.document;
|
|
76
|
+
if (!document?.defaultView) {
|
|
77
|
+
throw new RuaKitError('UNAVAILABLE', 'RuaKit requires a browser document.');
|
|
78
|
+
}
|
|
79
|
+
let active: ExecutionOptions | undefined;
|
|
80
|
+
let busy = false;
|
|
81
|
+
let currentSignal: AbortSignal | undefined;
|
|
82
|
+
let settled: Promise<void> | undefined;
|
|
83
|
+
let sequence = 0;
|
|
84
|
+
const observations = new WeakMap<PageState, PageState>();
|
|
85
|
+
const baselines = new WeakMap<PageState, PageState>();
|
|
86
|
+
const dom = createDomRuntime({
|
|
87
|
+
root: document,
|
|
88
|
+
timeoutMs: 8000,
|
|
89
|
+
onAction: (event) => active?.onAction?.(event),
|
|
90
|
+
get actionDelayMs() {
|
|
91
|
+
return active?.actionDelayMs ?? 0;
|
|
92
|
+
},
|
|
93
|
+
get scrollToTarget() {
|
|
94
|
+
return active?.scrollToTarget ?? false;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function snapshot(): PageState {
|
|
99
|
+
const state = dom.getState();
|
|
100
|
+
// Keep our own copy so callers cannot authorize targets by editing a snapshot.
|
|
101
|
+
const original = structuredClone(state);
|
|
102
|
+
observations.set(state, original);
|
|
103
|
+
baselines.set(state, original);
|
|
104
|
+
return state;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function notify(settings: ExecutionOptions, message: string): void {
|
|
108
|
+
try {
|
|
109
|
+
settings.onProgress?.(message);
|
|
110
|
+
} catch {
|
|
111
|
+
/* Feedback is observational. */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function phase(settings: ExecutionOptions, value: RequestPhase): void {
|
|
116
|
+
try {
|
|
117
|
+
settings.onPhaseChange?.(value);
|
|
118
|
+
} catch {
|
|
119
|
+
/* Feedback is observational. */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function own<T>(
|
|
124
|
+
settings: ExecutionOptions,
|
|
125
|
+
perform: (signal: AbortSignal) => Promise<T>,
|
|
126
|
+
): Promise<T> {
|
|
127
|
+
if (busy) {
|
|
128
|
+
// React Strict Mode can start a replacement before the cancelled call's
|
|
129
|
+
// finally block runs. Wait for that owner rather than overlapping actions.
|
|
130
|
+
if (currentSignal?.aborted) {
|
|
131
|
+
await settled;
|
|
132
|
+
return own(settings, perform);
|
|
133
|
+
}
|
|
134
|
+
throw new RuaKitError('BUSY', 'A RuaKit request is already running.');
|
|
135
|
+
}
|
|
136
|
+
busy = true;
|
|
137
|
+
let release!: () => void;
|
|
138
|
+
settled = new Promise<void>((resolve) => {
|
|
139
|
+
release = resolve;
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
validateRequestTimeout(settings.timeoutMs ?? 8000);
|
|
143
|
+
const operation = createRequestOperation(settings.requestTimeoutMs ?? 60000, settings.signal);
|
|
144
|
+
currentSignal = operation.signal;
|
|
145
|
+
active = settings;
|
|
146
|
+
try {
|
|
147
|
+
operation.signal.throwIfAborted();
|
|
148
|
+
return await perform(operation.signal);
|
|
149
|
+
} finally {
|
|
150
|
+
operation.dispose();
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
active = undefined;
|
|
154
|
+
currentSignal = undefined;
|
|
155
|
+
busy = false;
|
|
156
|
+
release();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function receipt(
|
|
161
|
+
events: string[],
|
|
162
|
+
decision: Decision,
|
|
163
|
+
level: 'observed' | 'confirmed' = 'observed',
|
|
164
|
+
): ExecutionResult {
|
|
165
|
+
return {
|
|
166
|
+
planId: `dom-${Date.now()}-${++sequence}`,
|
|
167
|
+
status: 'triggered',
|
|
168
|
+
events,
|
|
169
|
+
...(decision.type === 'complete'
|
|
170
|
+
? {
|
|
171
|
+
goalCompleted: true as const,
|
|
172
|
+
message: decision.message,
|
|
173
|
+
verification: { level, checks: decision.checks },
|
|
174
|
+
}
|
|
175
|
+
: {}),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function apply(
|
|
180
|
+
value: unknown,
|
|
181
|
+
source: PageState,
|
|
182
|
+
signal: AbortSignal,
|
|
183
|
+
settings: ExecutionOptions,
|
|
184
|
+
verification?: {
|
|
185
|
+
baseline: PageState;
|
|
186
|
+
checks: CompletionCheck[] | undefined;
|
|
187
|
+
prompt: string | undefined;
|
|
188
|
+
requirePreparation?: boolean;
|
|
189
|
+
},
|
|
190
|
+
): Promise<Decision> {
|
|
191
|
+
signal.throwIfAborted();
|
|
192
|
+
const observed = observations.get(source);
|
|
193
|
+
if (!observed) {
|
|
194
|
+
throw new RuaKitError('INVALID_PLAN', 'Use an unused snapshot from this Rua session.');
|
|
195
|
+
}
|
|
196
|
+
const decision = parsePayload(
|
|
197
|
+
decisionSchema,
|
|
198
|
+
value,
|
|
199
|
+
'INVALID_PLAN',
|
|
200
|
+
'The planner returned an invalid decision.',
|
|
201
|
+
);
|
|
202
|
+
observations.delete(source);
|
|
203
|
+
notify(settings, decision.message);
|
|
204
|
+
signal.throwIfAborted();
|
|
205
|
+
if (decision.type === 'clarify') {
|
|
206
|
+
throw new ClarificationNeededError({
|
|
207
|
+
type: 'clarification',
|
|
208
|
+
question: decision.message,
|
|
209
|
+
options: [],
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (decision.type === 'unsupported') {
|
|
213
|
+
throw new RuaKitError('UNAVAILABLE', decision.message);
|
|
214
|
+
}
|
|
215
|
+
if (decision.type === 'complete') {
|
|
216
|
+
const checks = verification?.checks ?? decision.checks;
|
|
217
|
+
if (JSON.stringify(checks) !== JSON.stringify(decision.checks)) {
|
|
218
|
+
throw new RuaKitError(
|
|
219
|
+
'INVALID_PLAN',
|
|
220
|
+
'Completion checks cannot change after execution begins.',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const baseline = verification?.baseline ?? observed;
|
|
224
|
+
if (
|
|
225
|
+
!evaluateCompletionChecks(checks, observed, baseline).passed ||
|
|
226
|
+
!evaluateCompletionChecks(checks, dom.getState(), baseline).passed
|
|
227
|
+
) {
|
|
228
|
+
throw new RuaKitError(
|
|
229
|
+
'INVALID_PLAN',
|
|
230
|
+
'The requested outcomes are not all present on the live page.',
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (settings.verifyCompletion) {
|
|
234
|
+
const confirmed = await runWithSignal(signal, () =>
|
|
235
|
+
settings.verifyCompletion!({
|
|
236
|
+
prompt: verification?.prompt,
|
|
237
|
+
checks: structuredClone(checks),
|
|
238
|
+
baseline: structuredClone(baseline),
|
|
239
|
+
snapshot: dom.getState(),
|
|
240
|
+
signal,
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
signal.throwIfAborted();
|
|
244
|
+
if (confirmed !== true)
|
|
245
|
+
throw new RuaKitError(
|
|
246
|
+
'VERIFICATION_FAILED',
|
|
247
|
+
'The application could not confirm the requested outcome.',
|
|
248
|
+
);
|
|
249
|
+
if (!evaluateCompletionChecks(checks, dom.getState(), baseline).passed) {
|
|
250
|
+
throw new RuaKitError(
|
|
251
|
+
'INVALID_PLAN',
|
|
252
|
+
'The page changed while the application verified completion.',
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return decision;
|
|
257
|
+
}
|
|
258
|
+
if (decision.type === 'prepare' || decision.type === 'screenshot') return decision;
|
|
259
|
+
if (verification?.requirePreparation && !verification.checks) {
|
|
260
|
+
throw new RuaKitError(
|
|
261
|
+
'INVALID_PLAN',
|
|
262
|
+
'Establish completion checks with prepare before taking actions.',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const step = decision.step;
|
|
266
|
+
const control = observed.controls.find(
|
|
267
|
+
(item) => item.target.id === step.target.id && item.target.version === step.target.version,
|
|
268
|
+
);
|
|
269
|
+
if (!control || !control.enabled) {
|
|
270
|
+
throw new RuaKitError('INVALID_PLAN', 'The planner selected an unavailable live DOM target.');
|
|
271
|
+
}
|
|
272
|
+
if (decision.type === 'approval') {
|
|
273
|
+
await requestApproval(decision, observed, signal, settings, verification?.prompt);
|
|
274
|
+
}
|
|
275
|
+
phase(settings, 'executing');
|
|
276
|
+
const action = { signal, timeoutMs: settings.timeoutMs ?? 8000 };
|
|
277
|
+
switch (step.type) {
|
|
278
|
+
case 'focus':
|
|
279
|
+
await dom.focus(step.target, action);
|
|
280
|
+
break;
|
|
281
|
+
case 'key':
|
|
282
|
+
await dom.pressKey(step.target, step.key, {
|
|
283
|
+
...action,
|
|
284
|
+
altKey: step.altKey,
|
|
285
|
+
ctrlKey: step.ctrlKey,
|
|
286
|
+
metaKey: step.metaKey,
|
|
287
|
+
shiftKey: step.shiftKey,
|
|
288
|
+
});
|
|
289
|
+
break;
|
|
290
|
+
case 'insertText':
|
|
291
|
+
await dom.insertText(step.target, step.text, action);
|
|
292
|
+
break;
|
|
293
|
+
case 'click':
|
|
294
|
+
await dom.click(step.target, action);
|
|
295
|
+
break;
|
|
296
|
+
case 'fill':
|
|
297
|
+
await dom.setValue(step.target, step.value, action);
|
|
298
|
+
break;
|
|
299
|
+
case 'select':
|
|
300
|
+
await dom.selectOption(step.target, step.values, action);
|
|
301
|
+
break;
|
|
302
|
+
case 'check':
|
|
303
|
+
await dom.setChecked(step.target, step.checked, action);
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
signal.throwIfAborted();
|
|
307
|
+
return decision;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const planEngine: PlanEngine = {
|
|
311
|
+
platform: document.defaultView?.navigator.platform ?? '',
|
|
312
|
+
snapshot, apply,
|
|
313
|
+
screenshot: (signal, timeoutMs) => dom.screenshot({ signal, timeoutMs }),
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
document,
|
|
318
|
+
snapshot,
|
|
319
|
+
executePlan: (plan, options = {}) => own(options, (signal) => executePlan(planEngine, plan, signal, options)),
|
|
320
|
+
createPlanTask: () => createPlanTask(planEngine, own),
|
|
321
|
+
run: (decision, settings) =>
|
|
322
|
+
own(settings, async (signal) => {
|
|
323
|
+
const baseline = settings.baseline ? baselines.get(settings.baseline) : undefined;
|
|
324
|
+
if (settings.baseline && !baseline)
|
|
325
|
+
throw new RuaKitError(
|
|
326
|
+
'INVALID_PLAN',
|
|
327
|
+
'Use an original session observation as the verification baseline.',
|
|
328
|
+
);
|
|
329
|
+
const checks = settings.checks ? completionChecksSchema.parse(settings.checks) : undefined;
|
|
330
|
+
const applied = await apply(decision, settings.snapshot, signal, settings, {
|
|
331
|
+
baseline: baseline ?? observations.get(settings.snapshot) ?? settings.snapshot,
|
|
332
|
+
checks,
|
|
333
|
+
prompt: undefined,
|
|
334
|
+
});
|
|
335
|
+
const result = receipt(
|
|
336
|
+
[applied.message.slice(0, 500)],
|
|
337
|
+
applied,
|
|
338
|
+
settings.verifyCompletion ? 'confirmed' : 'observed',
|
|
339
|
+
);
|
|
340
|
+
if (applied.type === 'screenshot')
|
|
341
|
+
result.screenshot = await dom.screenshot({
|
|
342
|
+
signal,
|
|
343
|
+
timeoutMs: settings.timeoutMs ?? 8000,
|
|
344
|
+
});
|
|
345
|
+
return result;
|
|
346
|
+
}),
|
|
347
|
+
plan: (prompt, request, settings = {}) =>
|
|
348
|
+
own(settings, async (signal) => {
|
|
349
|
+
const history: Decision[] = [];
|
|
350
|
+
const events: string[] = [];
|
|
351
|
+
let checks: CompletionCheck[] | undefined;
|
|
352
|
+
let baseline: PageState | undefined;
|
|
353
|
+
let image: Screenshot | undefined;
|
|
354
|
+
notify(settings, REQUEST_MESSAGES.observing);
|
|
355
|
+
while (true) {
|
|
356
|
+
signal.throwIfAborted();
|
|
357
|
+
phase(settings, 'observing');
|
|
358
|
+
const source = snapshot();
|
|
359
|
+
baseline ??= structuredClone(source);
|
|
360
|
+
if (image && image.url !== source.url) image = undefined;
|
|
361
|
+
const input = parsePayload(
|
|
362
|
+
decisionRequestSchema,
|
|
363
|
+
{
|
|
364
|
+
platform: document.defaultView?.navigator.platform ?? '',
|
|
365
|
+
prompt,
|
|
366
|
+
clarifications: settings.clarifications ?? [],
|
|
367
|
+
snapshot: source,
|
|
368
|
+
history: history.slice(-20),
|
|
369
|
+
checks: checks ? structuredClone(checks) : null,
|
|
370
|
+
canScreenshot: true,
|
|
371
|
+
...(image ? { screenshot: image } : {}),
|
|
372
|
+
},
|
|
373
|
+
'INVALID_REQUEST',
|
|
374
|
+
'Enter a valid request for the current application page.',
|
|
375
|
+
);
|
|
376
|
+
phase(settings, 'planning');
|
|
377
|
+
const decision = await apply(
|
|
378
|
+
await runWithSignal(signal, () => request(input, signal)),
|
|
379
|
+
source,
|
|
380
|
+
signal,
|
|
381
|
+
settings,
|
|
382
|
+
{ baseline, checks, prompt, requirePreparation: true },
|
|
383
|
+
);
|
|
384
|
+
events.push(decision.message.slice(0, 500));
|
|
385
|
+
if (decision.type === 'complete') {
|
|
386
|
+
return receipt(events, decision, settings.verifyCompletion ? 'confirmed' : 'observed');
|
|
387
|
+
}
|
|
388
|
+
if (decision.type === 'prepare') {
|
|
389
|
+
if (checks)
|
|
390
|
+
throw new RuaKitError(
|
|
391
|
+
'INVALID_PLAN',
|
|
392
|
+
'Completion checks cannot change after preparation.',
|
|
393
|
+
);
|
|
394
|
+
checks = structuredClone(decision.checks);
|
|
395
|
+
}
|
|
396
|
+
if (decision.type === 'screenshot') {
|
|
397
|
+
if (image)
|
|
398
|
+
throw new RuaKitError(
|
|
399
|
+
'INVALID_PLAN',
|
|
400
|
+
'Use the supplied screenshot before requesting another image.',
|
|
401
|
+
);
|
|
402
|
+
image = await dom.screenshot({ signal, timeoutMs: settings.timeoutMs ?? 8000 });
|
|
403
|
+
} else image = undefined;
|
|
404
|
+
history.push(decision);
|
|
405
|
+
}
|
|
406
|
+
}),
|
|
407
|
+
};
|
|
408
|
+
}
|