@logictan/dsh-browser-agent 0.1.0
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/README.md +60 -0
- package/cordis.patch.yml +18 -0
- package/lib/actions.js +155 -0
- package/lib/cdp.js +73 -0
- package/lib/client.js +823 -0
- package/lib/config.js +82 -0
- package/lib/execute.js +106 -0
- package/lib/index.js +104 -0
- package/lib/loop.js +190 -0
- package/lib/observe.js +214 -0
- package/lib/request.js +164 -0
- package/lib/typesafe.js +86 -0
- package/lib/validate.js +72 -0
- package/lib/value.js +103 -0
- package/package.json +90 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration surface of the browser agent.
|
|
3
|
+
*
|
|
4
|
+
* The settings namespace is the one contract three surfaces share: the host
|
|
5
|
+
* half reads it when a run starts, the Plugins-page card edits it, and the
|
|
6
|
+
* client half's field list mirrors it. The card ships as plain browser code and
|
|
7
|
+
* cannot import this module, so the two field lists are kept in step by hand —
|
|
8
|
+
* changing a key here means changing `src/client.js` too.
|
|
9
|
+
*
|
|
10
|
+
* @module @logictan/dsh-browser-agent/config
|
|
11
|
+
*/
|
|
12
|
+
import z from '@deepseek-ai/schemastery';
|
|
13
|
+
import { DEFAULT_ENDPOINT as DEFAULT_CDP_ENDPOINT } from './cdp.js';
|
|
14
|
+
import { DEFAULT_MAX_STEPS } from './loop.js';
|
|
15
|
+
import { DEFAULT_ENDPOINT as DEFAULT_TYPESAFE_ENDPOINT, DEFAULT_MODEL } from './typesafe.js';
|
|
16
|
+
|
|
17
|
+
/** Settings namespace the host half registers and the card binds. */
|
|
18
|
+
export const SETTINGS_NAMESPACE = 'browser-agent';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Schema of the `browser-agent` settings section.
|
|
22
|
+
*
|
|
23
|
+
* The TypeSafe key is `role('secret')`: it is stored in the local settings
|
|
24
|
+
* document and stripped by every redacting surface, including config sync. That
|
|
25
|
+
* is a deliberate consequence — a secret never leaves the machine that entered
|
|
26
|
+
* it — so the key must be typed once per device.
|
|
27
|
+
*
|
|
28
|
+
* It deliberately carries NO default. The redaction sidecar reports
|
|
29
|
+
* `set: value !== undefined` against the RESOLVED value, so `.default('')`
|
|
30
|
+
* would make the key look permanently present: the card could never tell an
|
|
31
|
+
* unset key from a set one, and its reset would clear a key it wrongly believed
|
|
32
|
+
* was there. Leaving the field optional is what keeps that flag truthful.
|
|
33
|
+
*
|
|
34
|
+
* The TYPE_TEXT route is three separate fields rather than one. Empty means
|
|
35
|
+
* "use the session's own current route", which is the only default that stays
|
|
36
|
+
* correct after config sync moves the file to a machine with different
|
|
37
|
+
* providers; a hard-coded model would point at a model the target may not have.
|
|
38
|
+
*/
|
|
39
|
+
export const Config = z.object({
|
|
40
|
+
typesafeApiKey: z.string().role('secret'),
|
|
41
|
+
typesafeEndpoint: z.string().default(DEFAULT_TYPESAFE_ENDPOINT),
|
|
42
|
+
typesafeModel: z.string().default(DEFAULT_MODEL),
|
|
43
|
+
cdpEndpoint: z.string().default(DEFAULT_CDP_ENDPOINT),
|
|
44
|
+
maxSteps: z.number().step(1).min(1).default(DEFAULT_MAX_STEPS),
|
|
45
|
+
textProvider: z.string().default(''),
|
|
46
|
+
textModel: z.string().default(''),
|
|
47
|
+
textReasoningEffort: z.string().default(''),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The route used for the TYPE_TEXT helper call.
|
|
52
|
+
*
|
|
53
|
+
* An explicitly configured pair wins. Otherwise the session's own route is
|
|
54
|
+
* used, mirroring how the host resolves an auxiliary model call — the same
|
|
55
|
+
* three-step fallback (configured pair, then the session's routed request, then
|
|
56
|
+
* its options) that `ctx-mem` uses for its fill call.
|
|
57
|
+
*
|
|
58
|
+
* @param config - the resolved settings section.
|
|
59
|
+
* @param agent - the calling agent, when the tool was invoked from a turn.
|
|
60
|
+
* @returns the route to call; empty provider/model means "unconfigured".
|
|
61
|
+
*/
|
|
62
|
+
export function resolveTextRoute(config, agent) {
|
|
63
|
+
if (config.textProvider !== '' && config.textModel !== '') {
|
|
64
|
+
return {
|
|
65
|
+
provider: config.textProvider,
|
|
66
|
+
model: config.textModel,
|
|
67
|
+
reasoningEffort: config.textReasoningEffort,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const routed = agent?.session?.requestHeader?.()?.config;
|
|
72
|
+
if (routed?.provider && routed?.model) {
|
|
73
|
+
return { provider: routed.provider, model: routed.model, reasoningEffort: '' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const options = agent?.options;
|
|
77
|
+
if (options?.provider && options?.model) {
|
|
78
|
+
return { provider: options.provider, model: options.model, reasoningEffort: '' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { provider: '', model: '', reasoningEffort: '' };
|
|
82
|
+
}
|
package/lib/execute.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action execution against the attached page.
|
|
3
|
+
*
|
|
4
|
+
* Every target is resolved from the observation's own identity map, and its
|
|
5
|
+
* geometry is re-read immediately before input: the page may have moved since
|
|
6
|
+
* the observation, and clicking stale coordinates would hit whatever now
|
|
7
|
+
* occupies them. A target that is gone is reported as a failed step rather than
|
|
8
|
+
* acted on blind, and the loop re-observes.
|
|
9
|
+
*
|
|
10
|
+
* The identity map is `window.__dshBrowserAgent`, written by `./observe.js`.
|
|
11
|
+
* The page-side functions below therefore read that one shape and nothing else;
|
|
12
|
+
* if the observer's cache is renamed, both modules must change together.
|
|
13
|
+
*
|
|
14
|
+
* @module @logictan/dsh-browser-agent/execute
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** How long to let the page settle after an interaction, in milliseconds. */
|
|
18
|
+
const SETTLE_MS = 120;
|
|
19
|
+
|
|
20
|
+
/** A dropdown option gets longer, because its list may still be rendering. */
|
|
21
|
+
const OPTION_SETTLE_MS = 200;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve one code-owned identity to its live node inside the page.
|
|
25
|
+
*
|
|
26
|
+
* Must stay self-contained: it is serialized and evaluated in the page.
|
|
27
|
+
* @param node - the identity from the observation.
|
|
28
|
+
* @returns whether the node is still actionable, after scrolling it into view.
|
|
29
|
+
*/
|
|
30
|
+
function resolveAndScroll(node) {
|
|
31
|
+
const element = window.__dshBrowserAgent?.nodes?.get(node);
|
|
32
|
+
if (!element || !element.isConnected) return false;
|
|
33
|
+
element.scrollIntoView({ block: 'center', inline: 'center' });
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Execute one decision against the page.
|
|
39
|
+
*
|
|
40
|
+
* @param input - the page, the resolved decision, and the TYPE_TEXT value
|
|
41
|
+
* provider (called only for `TYPE_TEXT`).
|
|
42
|
+
* @returns a short human-readable description of what was done.
|
|
43
|
+
* @throws {Error} when the target is no longer actionable, or the value
|
|
44
|
+
* provider could not produce a value.
|
|
45
|
+
*/
|
|
46
|
+
export async function execute(input) {
|
|
47
|
+
const { page, decision, valueProvider } = input;
|
|
48
|
+
|
|
49
|
+
if (decision.kind === 'done' || decision.kind === 'blocked') return decision.kind;
|
|
50
|
+
if (decision.kind === 'wait') {
|
|
51
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
52
|
+
return 'waited for the page';
|
|
53
|
+
}
|
|
54
|
+
if (decision.kind === 'scroll') {
|
|
55
|
+
await page.evaluate((delta) => window.scrollBy(0, delta), decision.delta);
|
|
56
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
57
|
+
return `scrolled by ${decision.delta}px`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const descriptor = decision.descriptor;
|
|
61
|
+
const live = await page.evaluate(resolveAndScroll, descriptor.node);
|
|
62
|
+
if (!live) throw new Error(`target [${decision.target}] is no longer on the page`);
|
|
63
|
+
|
|
64
|
+
if (decision.kind === 'click') {
|
|
65
|
+
await page.evaluate((node) => {
|
|
66
|
+
window.__dshBrowserAgent.nodes.get(node).click();
|
|
67
|
+
}, descriptor.node);
|
|
68
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
69
|
+
return `clicked ${descriptor.label}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (decision.kind === 'fill') {
|
|
73
|
+
const value = await valueProvider(descriptor);
|
|
74
|
+
await page.evaluate(
|
|
75
|
+
({ node, text }) => {
|
|
76
|
+
const element = window.__dshBrowserAgent.nodes.get(node);
|
|
77
|
+
element.focus();
|
|
78
|
+
if (element.isContentEditable) element.textContent = text;
|
|
79
|
+
else element.value = text;
|
|
80
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
81
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
82
|
+
},
|
|
83
|
+
{ node: descriptor.node, text: value },
|
|
84
|
+
);
|
|
85
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
86
|
+
return `typed "${value}" into ${descriptor.label}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (decision.kind === 'select') {
|
|
90
|
+
await page.evaluate(
|
|
91
|
+
({ node, optionIndex }) => {
|
|
92
|
+
const element = window.__dshBrowserAgent.nodes.get(node);
|
|
93
|
+
const option = element.options[optionIndex - 1];
|
|
94
|
+
if (option === undefined) throw new Error('the option is no longer offered');
|
|
95
|
+
element.value = option.value;
|
|
96
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
97
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
98
|
+
},
|
|
99
|
+
{ node: descriptor.node, optionIndex: descriptor.optionIndex },
|
|
100
|
+
);
|
|
101
|
+
await page.waitForTimeout(OPTION_SETTLE_MS);
|
|
102
|
+
return `selected ${descriptor.label}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw new Error(`unsupported operation ${decision.operation}`);
|
|
106
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-browser-agent — host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers two things: the `browser-agent` settings namespace, and the
|
|
5
|
+
* `browser_agent` model tool that runs the decision loop over CDP against the
|
|
6
|
+
* user's own Chrome.
|
|
7
|
+
*
|
|
8
|
+
* The tool is registered through `ctx.get('tools')` rather than the `ctx.tools`
|
|
9
|
+
* property. Cordis requires a declared `inject` before a service property may
|
|
10
|
+
* be read, and `tools` is genuinely optional here — a headless deployment that
|
|
11
|
+
* composes no tool registry still has a working settings namespace — so it must
|
|
12
|
+
* not be injected. Reading it optionally is the same choice
|
|
13
|
+
* `dsh-config-manager`'s `registerModelTools` makes, for the same reason.
|
|
14
|
+
*
|
|
15
|
+
* @module @logictan/dsh-browser-agent
|
|
16
|
+
*/
|
|
17
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
18
|
+
|
|
19
|
+
import { attach, activePage } from './cdp.js';
|
|
20
|
+
import { Config, SETTINGS_NAMESPACE, resolveTextRoute } from './config.js';
|
|
21
|
+
import { run } from './loop.js';
|
|
22
|
+
|
|
23
|
+
/** Plugin row id; must equal the row id in `cordis.patch.yml`. */
|
|
24
|
+
export const name = 'browser-agent';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Services this plugin needs before it can mount.
|
|
28
|
+
*
|
|
29
|
+
* `llm` is required: the TYPE_TEXT step cannot work without it, and a silent
|
|
30
|
+
* degradation there would be worse than a load failure. `settings` is required
|
|
31
|
+
* for the same reason — every run reads its configuration from it.
|
|
32
|
+
*/
|
|
33
|
+
export const inject = ['settings', 'llm'];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Register the `browser_agent` tool.
|
|
37
|
+
*
|
|
38
|
+
* @param ctx - the plugin context.
|
|
39
|
+
*/
|
|
40
|
+
export function apply(ctx) {
|
|
41
|
+
const settings = ctx.settings;
|
|
42
|
+
const llm = ctx.llm;
|
|
43
|
+
const tools = ctx.get('tools');
|
|
44
|
+
|
|
45
|
+
// The namespace must be registered before anything reads or writes it: an
|
|
46
|
+
// unregistered namespace makes `settings.get` return undefined and every
|
|
47
|
+
// Plugins-page write fail with "settings namespace ... is not registered".
|
|
48
|
+
settings.register(SETTINGS_NAMESPACE, Config);
|
|
49
|
+
|
|
50
|
+
const definition = defineTool({
|
|
51
|
+
name: 'browser_agent',
|
|
52
|
+
description:
|
|
53
|
+
"Drive the user's own Chrome to accomplish a goal. Navigates to `url` and runs an observation/decision loop " +
|
|
54
|
+
'until the goal is met, then returns a structured trace. Requires Chrome to be running with a debugging port ' +
|
|
55
|
+
'(the dedicated-profile command is in the plugin README) and a TypeSafe API key in the plugin settings.',
|
|
56
|
+
parameters: {
|
|
57
|
+
url: { type: 'string', description: 'Page to open before starting.', required: true },
|
|
58
|
+
goal: { type: 'string', description: 'What must be true when the task is finished.', required: true },
|
|
59
|
+
},
|
|
60
|
+
output: {
|
|
61
|
+
schema: { type: 'json', description: 'Structured trace: status, steps, decision count, final url.' },
|
|
62
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
63
|
+
},
|
|
64
|
+
async execute(args, exec) {
|
|
65
|
+
const current = Config(settings.get(SETTINGS_NAMESPACE) ?? {});
|
|
66
|
+
// An unset optional field resolves to undefined; a cleared one to ''.
|
|
67
|
+
if (current.typesafeApiKey === undefined || current.typesafeApiKey === '') {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'browser_agent: no TypeSafe API key is configured. ' +
|
|
70
|
+
'Open Settings → Plugins → browser-agent and paste a key from https://console.typesafe.ai/keys.',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const browser = await attach(current.cdpEndpoint);
|
|
75
|
+
try {
|
|
76
|
+
const page = await activePage(browser);
|
|
77
|
+
return await run({
|
|
78
|
+
url: args.url,
|
|
79
|
+
goal: args.goal,
|
|
80
|
+
page,
|
|
81
|
+
llm,
|
|
82
|
+
config: {
|
|
83
|
+
apiKey: current.typesafeApiKey,
|
|
84
|
+
endpoint: current.typesafeEndpoint,
|
|
85
|
+
model: current.typesafeModel,
|
|
86
|
+
maxSteps: current.maxSteps,
|
|
87
|
+
textRoute: resolveTextRoute(current, exec),
|
|
88
|
+
},
|
|
89
|
+
signal: exec.signal,
|
|
90
|
+
});
|
|
91
|
+
} finally {
|
|
92
|
+
// Disconnect, never close: the browser is the user's, and closing it
|
|
93
|
+
// would take their windows and tabs with it.
|
|
94
|
+
await browser.close();
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (tools === null || tools === undefined || typeof tools !== 'object') {
|
|
100
|
+
ctx.logger?.warn?.('browser_agent: the tools service is unavailable; the tool was not registered.');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
ctx.effect(() => tools.register(definition), 'browser-agent: browser_agent tool');
|
|
104
|
+
}
|
package/lib/loop.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision loop.
|
|
3
|
+
*
|
|
4
|
+
* Observe, ask, validate, execute — repeated until the model answers `DONE` or
|
|
5
|
+
* `BLOCKED`, or a cap is reached. Every iteration re-observes rather than
|
|
6
|
+
* reusing the previous table, because the page is the only source of truth
|
|
7
|
+
* about what is currently clickable and an identity from a stale observation
|
|
8
|
+
* may no longer resolve.
|
|
9
|
+
*
|
|
10
|
+
* Both caps are hard stops that still return a trace: a run that hits one has
|
|
11
|
+
* not failed, it has run out of budget, and the caller needs the trace to see
|
|
12
|
+
* where it got to. `DONE` is the model's own claim and is reported as such —
|
|
13
|
+
* this plugin has no independent way to verify the goal was met.
|
|
14
|
+
*
|
|
15
|
+
* @module @logictan/dsh-browser-agent/loop
|
|
16
|
+
*/
|
|
17
|
+
import { actionSpace } from './actions.js';
|
|
18
|
+
import { buildRequest, resolveDecision } from './request.js';
|
|
19
|
+
import { ask } from './typesafe.js';
|
|
20
|
+
import { execute } from './execute.js';
|
|
21
|
+
import { SNAPSHOT_LIMITS, snapshot } from './observe.js';
|
|
22
|
+
import { fieldValue } from './value.js';
|
|
23
|
+
|
|
24
|
+
/** Browser actions allowed per run. */
|
|
25
|
+
export const DEFAULT_MAX_STEPS = 60;
|
|
26
|
+
|
|
27
|
+
/** Decision requests allowed per run; two per step is the observed ratio. */
|
|
28
|
+
export const MAX_DECISIONS = 120;
|
|
29
|
+
|
|
30
|
+
/** How many times an observation is retried while a navigation is in flight. */
|
|
31
|
+
const OBSERVE_ATTEMPTS = 5;
|
|
32
|
+
|
|
33
|
+
/** How long to wait between those retries, in milliseconds. */
|
|
34
|
+
const OBSERVE_RETRY_MS = 120;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a failed evaluation was the old document being torn down.
|
|
38
|
+
*
|
|
39
|
+
* A click that navigates destroys the document the evaluation was bound to, so
|
|
40
|
+
* the call is rejected rather than returning a value. Playwright reports that
|
|
41
|
+
* as a context-destroyed error; any other rejection is a real fault.
|
|
42
|
+
*
|
|
43
|
+
* @param cause - the rejection from `page.evaluate`.
|
|
44
|
+
* @returns whether the failure was a destroyed execution context.
|
|
45
|
+
*/
|
|
46
|
+
function isContextDestroyed(cause) {
|
|
47
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
48
|
+
return message.includes('Execution context was destroyed') || message.includes('Cannot find context with specified id');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Observe the page, tolerating a navigation that is still in flight.
|
|
53
|
+
*
|
|
54
|
+
* A click may navigate, and the loop re-observes immediately afterwards. That
|
|
55
|
+
* observation can land while the old document is gone and the new one is not
|
|
56
|
+
* ready — the expected consequence of the action just taken, not a failure. It
|
|
57
|
+
* is retried against the document that replaces it; a run that treated it as
|
|
58
|
+
* fatal would end the moment the agent clicked its first link.
|
|
59
|
+
*
|
|
60
|
+
* Exported because the retry policy is a seam worth testing on its own: a
|
|
61
|
+
* non-navigating rejection must propagate unchanged.
|
|
62
|
+
*
|
|
63
|
+
* @param page - the attached page.
|
|
64
|
+
* @returns the page-side snapshot, or `null` when the document has no body.
|
|
65
|
+
* @throws {Error} any rejection that is not a destroyed execution context, and
|
|
66
|
+
* a context-destroyed rejection that outlives {@link OBSERVE_ATTEMPTS}.
|
|
67
|
+
*/
|
|
68
|
+
export async function observe(page) {
|
|
69
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
70
|
+
try {
|
|
71
|
+
return await page.evaluate(snapshot, SNAPSHOT_LIMITS);
|
|
72
|
+
} catch (cause) {
|
|
73
|
+
if (attempt >= OBSERVE_ATTEMPTS || !isContextDestroyed(cause)) throw cause;
|
|
74
|
+
await page.waitForTimeout(OBSERVE_RETRY_MS);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run one task to completion.
|
|
81
|
+
*
|
|
82
|
+
* @param input - the page, the goal, the settings, the `ctx.llm` service, and
|
|
83
|
+
* an abort signal.
|
|
84
|
+
* @returns a structured trace: the steps taken, how the run ended, and the
|
|
85
|
+
* decision count.
|
|
86
|
+
*/
|
|
87
|
+
export async function run(input) {
|
|
88
|
+
const { page, goal, config, llm, signal } = input;
|
|
89
|
+
|
|
90
|
+
await page.goto(input.url, { waitUntil: 'domcontentloaded' });
|
|
91
|
+
await page.waitForTimeout(200);
|
|
92
|
+
|
|
93
|
+
const history = [];
|
|
94
|
+
const steps = [];
|
|
95
|
+
let decisions = 0;
|
|
96
|
+
let status = 'max-steps';
|
|
97
|
+
|
|
98
|
+
for (let step = 1; step <= config.maxSteps; step += 1) {
|
|
99
|
+
if (signal?.aborted) {
|
|
100
|
+
status = 'cancelled';
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const observation = await observe(page);
|
|
105
|
+
if (observation === null) {
|
|
106
|
+
status = 'blocked';
|
|
107
|
+
history.push({ action: 'the page has no body', kind: 'BLOCKED', text: '', page_changed: false });
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const table = actionSpace(observation.actions, { controls: observation.controls });
|
|
112
|
+
const body = buildRequest({
|
|
113
|
+
goal,
|
|
114
|
+
page: observation,
|
|
115
|
+
elements: table.elements,
|
|
116
|
+
targets: table.targets,
|
|
117
|
+
controls: table.controls,
|
|
118
|
+
history,
|
|
119
|
+
model: config.model,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (decisions >= MAX_DECISIONS) {
|
|
123
|
+
status = 'max-decisions';
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
decisions += 1;
|
|
127
|
+
|
|
128
|
+
const response = await ask({ body, endpoint: config.endpoint, apiKey: config.apiKey, signal });
|
|
129
|
+
const decision = resolveDecision({ answers: response.answers, targets: table.targets, controls: table.controls });
|
|
130
|
+
|
|
131
|
+
if (decision.kind === 'done' || decision.kind === 'blocked') {
|
|
132
|
+
status = decision.kind;
|
|
133
|
+
steps.push({
|
|
134
|
+
step,
|
|
135
|
+
operation: decision.operation,
|
|
136
|
+
target: decision.target,
|
|
137
|
+
label: decision.descriptor?.label ?? null,
|
|
138
|
+
confidence: decision.confidence,
|
|
139
|
+
result: decision.kind,
|
|
140
|
+
});
|
|
141
|
+
history.push({ action: decision.operation, kind: decision.operation, text: '', page_changed: false });
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let result;
|
|
146
|
+
try {
|
|
147
|
+
result = await execute({
|
|
148
|
+
page,
|
|
149
|
+
decision,
|
|
150
|
+
valueProvider: (descriptor) =>
|
|
151
|
+
fieldValue({
|
|
152
|
+
llm,
|
|
153
|
+
route: config.textRoute,
|
|
154
|
+
goal,
|
|
155
|
+
descriptor,
|
|
156
|
+
page: observation,
|
|
157
|
+
history,
|
|
158
|
+
signal,
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
} catch (cause) {
|
|
162
|
+
// A target that vanished between observation and execution is a normal
|
|
163
|
+
// race, not a fault: record it and let the next observation re-decide.
|
|
164
|
+
result = `failed: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
steps.push({
|
|
168
|
+
step,
|
|
169
|
+
operation: decision.operation,
|
|
170
|
+
target: decision.target,
|
|
171
|
+
label: decision.descriptor?.label ?? null,
|
|
172
|
+
confidence: decision.confidence,
|
|
173
|
+
result,
|
|
174
|
+
});
|
|
175
|
+
history.push({
|
|
176
|
+
action: `${decision.operation} ${decision.descriptor?.label ?? ''}`.trim(),
|
|
177
|
+
kind: decision.operation,
|
|
178
|
+
text: decision.kind === 'fill' ? result : '',
|
|
179
|
+
page_changed: true,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
status,
|
|
185
|
+
goal,
|
|
186
|
+
url: page.url(),
|
|
187
|
+
steps,
|
|
188
|
+
decisions,
|
|
189
|
+
};
|
|
190
|
+
}
|