amalgm 0.1.144 → 0.1.145
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/apps/supervisor.js +142 -25
- package/runtime/scripts/amalgm-mcp/browser/auth-tools.js +49 -10
- package/runtime/scripts/amalgm-mcp/browser/cua-tools.js +84 -111
- package/runtime/scripts/amalgm-mcp/browser/delta.js +254 -0
- package/runtime/scripts/amalgm-mcp/browser/tools.js +166 -12
- package/runtime/scripts/amalgm-mcp/lib/tool-result.js +13 -0
- package/runtime/scripts/amalgm-mcp/tests/apps-crash-loop.test.js +85 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-tools-surface.test.js +165 -0
- package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +18 -15
- package/runtime/scripts/amalgm-mcp/toolbox/loadout-context.js +7 -1
- package/runtime/scripts/chat-core/tool-shape.js +10 -1
- package/runtime/scripts/platform-context.txt +5 -2
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Action-result deltas — the evidence contract behind "every action reports
|
|
5
|
+
* its own result".
|
|
6
|
+
*
|
|
7
|
+
* Success must mean the intended browser state changed, not that a command
|
|
8
|
+
* was dispatched. So around each mutating verb the wrapper measures:
|
|
9
|
+
* before — the facts that decide whether the action CAN work (target
|
|
10
|
+
* enabled, current field value, keyboard-event baseline), using
|
|
11
|
+
* agent-browser's own target resolution (@ref/CSS), never a
|
|
12
|
+
* second resolver;
|
|
13
|
+
* after — what actually changed (URL/title, field value, key events,
|
|
14
|
+
* blocking dialog), one settle window later.
|
|
15
|
+
*
|
|
16
|
+
* The result is a typed delta object (buildable and testable without a
|
|
17
|
+
* browser) rendered to compact text. Every field is a measured fact or
|
|
18
|
+
* absent — no confidence scores, no interpretation, no model calls. Probe
|
|
19
|
+
* failures degrade to absent fields and never fail the action they decorate.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const attach = require('./attach');
|
|
23
|
+
|
|
24
|
+
/** One in-page round trip: url + title. Same probe engine.js batches. */
|
|
25
|
+
const STATE_PROBE = ['eval', 'JSON.stringify({url: location.href, title: document.title})'];
|
|
26
|
+
|
|
27
|
+
/** Idempotent capture-phase keydown counter; returns the current count.
|
|
28
|
+
* Navigation resets it — probes just degrade to absent. */
|
|
29
|
+
const KEY_COUNTER_PROBE = ['eval', `(() => {
|
|
30
|
+
if (!window.__amalgmKeyGuard) {
|
|
31
|
+
window.__amalgmKeyGuard = { count: 0 };
|
|
32
|
+
document.addEventListener('keydown', () => { window.__amalgmKeyGuard.count += 1; }, true);
|
|
33
|
+
}
|
|
34
|
+
return window.__amalgmKeyGuard.count;
|
|
35
|
+
})()`];
|
|
36
|
+
|
|
37
|
+
/** Brief settle so the page reacts (handlers run, sync navigations start)
|
|
38
|
+
* before we look. Not a load wait — deltas must stay cheap. */
|
|
39
|
+
const SETTLE_MS = 150;
|
|
40
|
+
|
|
41
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
42
|
+
|
|
43
|
+
/** A press dispatching more events than this is not a keypress — re-check
|
|
44
|
+
* after a pause and call out a runaway input flood (a real stress-test P0:
|
|
45
|
+
* 34K+ synthetic keydowns/minute after one modifier combo). */
|
|
46
|
+
const KEY_FLOOD_THRESHOLD = 15;
|
|
47
|
+
const KEY_FLOOD_RECHECK_MS = 200;
|
|
48
|
+
|
|
49
|
+
/** session -> { url, title } as of the last probe or verb that knew it. */
|
|
50
|
+
const lastStates = new Map();
|
|
51
|
+
|
|
52
|
+
function parseProbe(result) {
|
|
53
|
+
// Batch entries wrap as {result: {origin, result}}, single runs as
|
|
54
|
+
// {data: {origin, result}} — .result must win, same as engine.parseState.
|
|
55
|
+
const data = result?.result ?? result?.data ?? result;
|
|
56
|
+
const raw = data && typeof data === 'object' && 'result' in data ? data.result : data;
|
|
57
|
+
try {
|
|
58
|
+
const { url = '', title = '' } = JSON.parse(String(raw));
|
|
59
|
+
return { url, title };
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Unwrap a single value out of an agent-browser command result. */
|
|
66
|
+
function unwrap(entry, key) {
|
|
67
|
+
const data = entry?.result ?? entry?.data ?? entry;
|
|
68
|
+
if (data && typeof data === 'object') {
|
|
69
|
+
if (key !== undefined && key in data) return data[key];
|
|
70
|
+
if ('result' in data) return data.result;
|
|
71
|
+
}
|
|
72
|
+
return data;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Verbs that already learned the page state (open/snapshot) record it here
|
|
76
|
+
* so the next action's delta has a real "before". */
|
|
77
|
+
function remember(session, state) {
|
|
78
|
+
if (state && state.url) lastStates.set(session, { url: state.url, title: state.title || '' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** text= targets go through find, which agent-browser's get/is commands
|
|
82
|
+
* don't take — element probes are skipped for them, fields stay absent. */
|
|
83
|
+
function probeable(target) {
|
|
84
|
+
return typeof target === 'string' && target.length > 0 && !target.startsWith('text=');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function tryRun(session, command, context) {
|
|
88
|
+
try {
|
|
89
|
+
return await attach.run(session, command, { timeoutMs: PROBE_TIMEOUT_MS, context });
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Measure the facts that decide whether the action can work. One extra
|
|
97
|
+
* round trip; every failure degrades to absent fields.
|
|
98
|
+
*/
|
|
99
|
+
async function beforeAction(session, { action, target } = {}, context) {
|
|
100
|
+
const before = { page: lastStates.get(session) || null };
|
|
101
|
+
if ((action === 'click' || action === 'select') && probeable(target)) {
|
|
102
|
+
const enabled = await tryRun(session, ['is', 'enabled', target], context);
|
|
103
|
+
if (enabled) before.enabled = Boolean(unwrap(enabled, 'enabled'));
|
|
104
|
+
}
|
|
105
|
+
if (action === 'fill' && probeable(target)) {
|
|
106
|
+
const entries = await tryRun(session, ['get', 'value', target], context);
|
|
107
|
+
if (entries) {
|
|
108
|
+
before.value = String(unwrap(entries, 'value') ?? '');
|
|
109
|
+
const enabled = await tryRun(session, ['is', 'enabled', target], context);
|
|
110
|
+
if (enabled) before.enabled = Boolean(unwrap(enabled, 'enabled'));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (action === 'press') {
|
|
114
|
+
const count = await tryRun(session, KEY_COUNTER_PROBE, context);
|
|
115
|
+
if (count) before.keyCount = Number(unwrap(count)) || 0;
|
|
116
|
+
}
|
|
117
|
+
return before;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Measure what changed and assemble the typed delta. Never throws.
|
|
122
|
+
* `expected` is the value a fill asked for, so a field that silently kept
|
|
123
|
+
* or mangled its content gets called out.
|
|
124
|
+
*/
|
|
125
|
+
async function afterAction(session, { action, target, before = {}, expected } = {}, context) {
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
|
127
|
+
const delta = { action: action || null, target: target || null, page: {}, warnings: [] };
|
|
128
|
+
if (typeof before.enabled === 'boolean') delta.targetEnabled = before.enabled;
|
|
129
|
+
if (before.enabled === false) {
|
|
130
|
+
delta.warnings.push(`Target ${target} was disabled when ${action || 'the action'} ran — it likely had no effect.`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// One batched round trip for the after-facts; a pending dialog fails the
|
|
134
|
+
// batch with a message that says so — that IS the delta then.
|
|
135
|
+
const commands = [];
|
|
136
|
+
if ((action === 'fill' || action === 'select') && probeable(target)) commands.push(['get', 'value', target]);
|
|
137
|
+
if (action === 'press') commands.push(KEY_COUNTER_PROBE);
|
|
138
|
+
commands.push(STATE_PROBE);
|
|
139
|
+
|
|
140
|
+
let entries = null;
|
|
141
|
+
try {
|
|
142
|
+
entries = await attach.runBatch(session, commands, { timeoutMs: PROBE_TIMEOUT_MS, context });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const message = String(err?.message || '');
|
|
145
|
+
if (/dialog/i.test(message)) {
|
|
146
|
+
delta.page.dialog = message;
|
|
147
|
+
return delta;
|
|
148
|
+
}
|
|
149
|
+
return delta; // probe failure: facts stay absent, action result stands
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let index = 0;
|
|
153
|
+
if ((action === 'fill' || action === 'select') && probeable(target)) {
|
|
154
|
+
const after = String(unwrap(entries[index], 'value') ?? '');
|
|
155
|
+
index += 1;
|
|
156
|
+
delta.value = { after };
|
|
157
|
+
if (typeof before.value === 'string') {
|
|
158
|
+
delta.value.before = before.value;
|
|
159
|
+
delta.value.changed = before.value !== after;
|
|
160
|
+
}
|
|
161
|
+
if (typeof expected === 'string' && after !== expected) {
|
|
162
|
+
delta.warnings.push(`Field value is "${clip(after, 80)}", not the requested "${clip(expected, 80)}".`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (action === 'press') {
|
|
166
|
+
const count = Number(unwrap(entries[index])) || 0;
|
|
167
|
+
index += 1;
|
|
168
|
+
if (typeof before.keyCount === 'number') {
|
|
169
|
+
const dispatched = Math.max(0, count - before.keyCount);
|
|
170
|
+
delta.keys = { dispatched };
|
|
171
|
+
if (dispatched > KEY_FLOOD_THRESHOLD) {
|
|
172
|
+
await new Promise((resolve) => setTimeout(resolve, KEY_FLOOD_RECHECK_MS));
|
|
173
|
+
const again = await tryRun(session, KEY_COUNTER_PROBE, context);
|
|
174
|
+
const later = Number(unwrap(again)) || count;
|
|
175
|
+
if (later > count) {
|
|
176
|
+
delta.keys.flood = true;
|
|
177
|
+
delta.warnings.push(`Runaway keyboard input: ${later - before.keyCount} keydown events and still firing. Reload the page to stop it.`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const state = parseProbe(entries[index]);
|
|
184
|
+
if (state && state.url) {
|
|
185
|
+
delta.page.url = state.url;
|
|
186
|
+
delta.page.title = state.title || '';
|
|
187
|
+
const prior = before.page;
|
|
188
|
+
if (prior && prior.url) delta.page.urlChanged = prior.url !== state.url;
|
|
189
|
+
remember(session, state);
|
|
190
|
+
}
|
|
191
|
+
return delta;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function clip(value, max) {
|
|
195
|
+
const text = String(value ?? '');
|
|
196
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Render the delta as compact lines: warnings first, then facts. */
|
|
200
|
+
function render(delta) {
|
|
201
|
+
if (!delta) return '';
|
|
202
|
+
const lines = delta.warnings.map((warning) => `⚠ ${warning}`);
|
|
203
|
+
if (delta.page.dialog) {
|
|
204
|
+
lines.push(`⚠ ${delta.page.dialog}`);
|
|
205
|
+
return lines.join('\n');
|
|
206
|
+
}
|
|
207
|
+
if (delta.value && delta.value.before !== undefined) {
|
|
208
|
+
lines.push(`→ Value: "${clip(delta.value.before, 80)}" → "${clip(delta.value.after, 80)}"`);
|
|
209
|
+
}
|
|
210
|
+
if (delta.keys && delta.keys.dispatched === 0) {
|
|
211
|
+
lines.push('→ No keydown events reached the page — the key may not have been delivered.');
|
|
212
|
+
}
|
|
213
|
+
if (delta.page.url) {
|
|
214
|
+
const label = delta.page.title ? `${delta.page.url} ("${delta.page.title}")` : delta.page.url;
|
|
215
|
+
if (delta.page.urlChanged) {
|
|
216
|
+
lines.push(`→ Navigated to ${label}`);
|
|
217
|
+
lines.push('Refs from the previous page are stale — re-run snapshot.');
|
|
218
|
+
} else {
|
|
219
|
+
lines.push(`→ Page: ${label}${delta.page.urlChanged === false ? ' (no navigation)' : ''}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return lines.join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Page-facts-only delta for verbs with no element target (dialog, tab, cua
|
|
227
|
+
* clicks). Returns rendered text; kept for those callers' simplicity.
|
|
228
|
+
*/
|
|
229
|
+
async function describeAfter(session, context, action = null) {
|
|
230
|
+
const delta = await afterAction(session, { action, before: { page: lastStates.get(session) || null } }, context);
|
|
231
|
+
return render(delta);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Cap tool output at `max` chars with an explicit truncation marker — silent
|
|
236
|
+
* truncation reads as "that was everything" when it wasn't.
|
|
237
|
+
*/
|
|
238
|
+
function clipOutput(text, max, hint = '') {
|
|
239
|
+
const value = String(text ?? '');
|
|
240
|
+
if (value.length <= max) return value;
|
|
241
|
+
const marker = `\n\n[Output truncated: showing ${max.toLocaleString()} of ${value.length.toLocaleString()} chars.${hint ? ` ${hint}` : ''}]`;
|
|
242
|
+
return value.slice(0, max) + marker;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = {
|
|
246
|
+
afterAction,
|
|
247
|
+
beforeAction,
|
|
248
|
+
clipOutput,
|
|
249
|
+
describeAfter,
|
|
250
|
+
lastStates,
|
|
251
|
+
parseProbe,
|
|
252
|
+
remember,
|
|
253
|
+
render,
|
|
254
|
+
};
|
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Browser core actions — the thin surface.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Verbs built on the agent-browser CLI (vercel-labs/agent-browser).
|
|
7
7
|
* The loop is: open → snapshot (@refs) → click/fill/select (@ref) →
|
|
8
8
|
* re-snapshot.
|
|
9
9
|
* Everything else agent-browser can do (scroll, hover, cookies, network
|
|
10
10
|
* mocking, traces, diffs, React inspection, ...) is reachable through `cli`
|
|
11
11
|
* without widening this schema.
|
|
12
12
|
*
|
|
13
|
+
* Every mutating verb reports its own result: after acting it probes the
|
|
14
|
+
* page once (delta.js) and answers in the same response what the agent
|
|
15
|
+
* would otherwise ask next — did we navigate, is a dialog blocking, where
|
|
16
|
+
* are we now. All large outputs carry explicit truncation markers.
|
|
17
|
+
*
|
|
13
18
|
* Action names are short verbs — the toolbox layer prefixes them, so agents
|
|
14
19
|
* see toolbox__browser_open, toolbox__browser_snapshot, and so on.
|
|
15
20
|
*
|
|
@@ -20,6 +25,34 @@
|
|
|
20
25
|
|
|
21
26
|
const { textResult, errorResult } = require('../lib/tool-result');
|
|
22
27
|
const engine = require('./engine');
|
|
28
|
+
const delta = require('./delta');
|
|
29
|
+
|
|
30
|
+
const SNAPSHOT_MAX_CHARS = 12_000;
|
|
31
|
+
const OUTPUT_MAX_CHARS = 15_000;
|
|
32
|
+
|
|
33
|
+
/** Action text + the post-action delta, in one response. */
|
|
34
|
+
function actionResult(text, deltaText) {
|
|
35
|
+
return textResult([text, deltaText].filter(Boolean).join('\n'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Rendered text for the agent + the typed delta object as metadata for
|
|
39
|
+
* anything programmatic (tests, UIs, validators). */
|
|
40
|
+
function evidenceResult(text, deltaObject) {
|
|
41
|
+
const result = actionResult(text, delta.render(deltaObject));
|
|
42
|
+
return { ...result, metadata: { browserDelta: deltaObject } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The before → act → after evidence pipeline shared by the element verbs.
|
|
47
|
+
* `act` runs the engine verb; measurement never fails the action.
|
|
48
|
+
*/
|
|
49
|
+
async function withEvidence({ action, args, ctx, expected, act, describe }) {
|
|
50
|
+
const session = engine.sessionFor(args, ctx);
|
|
51
|
+
const before = await delta.beforeAction(session, { action, target: args.target }, ctx);
|
|
52
|
+
const result = await act();
|
|
53
|
+
const deltaObject = await delta.afterAction(session, { action, target: args.target, before, expected }, ctx);
|
|
54
|
+
return evidenceResult(describe(result), deltaObject);
|
|
55
|
+
}
|
|
23
56
|
|
|
24
57
|
const sessionProperty = {
|
|
25
58
|
session: {
|
|
@@ -54,6 +87,14 @@ module.exports = [
|
|
|
54
87
|
if (!args.url) return errorResult('url is required');
|
|
55
88
|
try {
|
|
56
89
|
const result = await engine.open(args, ctx);
|
|
90
|
+
delta.remember(result.session, result);
|
|
91
|
+
// The audit contract for open: requested URL, final URL, error page
|
|
92
|
+
// or not. A chrome-error page means the navigation failed and the
|
|
93
|
+
// previous page is gone — success text here would be a lie.
|
|
94
|
+
if (/^chrome-error:/.test(result.url || '')) {
|
|
95
|
+
return errorResult(`Open failed: the browser is on an error page (${result.url}) after requesting ${args.url}. `
|
|
96
|
+
+ 'The previous page is gone — open a working URL to continue.');
|
|
97
|
+
}
|
|
57
98
|
return textResult(line(result, `Opened: ${result.title || args.url}`)
|
|
58
99
|
+ '\n\nNext: snapshot lists interactive elements as @refs.');
|
|
59
100
|
} catch (err) {
|
|
@@ -68,8 +109,11 @@ module.exports = [
|
|
|
68
109
|
async handler(args, ctx) {
|
|
69
110
|
try {
|
|
70
111
|
const result = await engine.snapshot(args, ctx);
|
|
112
|
+
delta.remember(result.session, result);
|
|
71
113
|
const header = `Page: ${result.title || ''}\nURL: ${result.url || ''}\n\n`;
|
|
72
|
-
|
|
114
|
+
const body = delta.clipOutput(result.snapshotText || '(empty page)', SNAPSHOT_MAX_CHARS,
|
|
115
|
+
'Elements past the cutoff have no refs here — scope with cli ["get","html","<css>"] or ["find","role","<role>","click","--name","<label>"].');
|
|
116
|
+
return textResult(header + body);
|
|
73
117
|
} catch (err) {
|
|
74
118
|
return errorResult(`Snapshot failed: ${err.message}`);
|
|
75
119
|
}
|
|
@@ -110,8 +154,13 @@ module.exports = [
|
|
|
110
154
|
async handler(args, ctx) {
|
|
111
155
|
if (!args.target) return errorResult('target is required (e.g. "@e3", "#submit", "text=Sign in")');
|
|
112
156
|
try {
|
|
113
|
-
|
|
114
|
-
|
|
157
|
+
return await withEvidence({
|
|
158
|
+
action: 'click',
|
|
159
|
+
args,
|
|
160
|
+
ctx,
|
|
161
|
+
act: () => engine.click(args, ctx),
|
|
162
|
+
describe: () => `Clicked ${args.target}`,
|
|
163
|
+
});
|
|
115
164
|
} catch (err) {
|
|
116
165
|
return errorResult(`Click failed: ${err.message}`);
|
|
117
166
|
}
|
|
@@ -133,8 +182,16 @@ module.exports = [
|
|
|
133
182
|
async handler(args, ctx) {
|
|
134
183
|
if (!args.target || typeof args.text !== 'string') return errorResult('target and text are required');
|
|
135
184
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
185
|
+
return await withEvidence({
|
|
186
|
+
action: 'fill',
|
|
187
|
+
args,
|
|
188
|
+
ctx,
|
|
189
|
+
// A submit navigates away — comparing the next page's field against
|
|
190
|
+
// the requested text would be a false warning.
|
|
191
|
+
expected: args.submit ? undefined : args.text,
|
|
192
|
+
act: () => engine.fill(args, ctx),
|
|
193
|
+
describe: (result) => `Filled ${args.target}${result.submitted ? ' and pressed Enter' : ''}`,
|
|
194
|
+
});
|
|
138
195
|
} catch (err) {
|
|
139
196
|
return errorResult(`Fill failed: ${err.message}`);
|
|
140
197
|
}
|
|
@@ -151,8 +208,13 @@ module.exports = [
|
|
|
151
208
|
async handler(args, ctx) {
|
|
152
209
|
if (!args.key) return errorResult('key is required');
|
|
153
210
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
211
|
+
return await withEvidence({
|
|
212
|
+
action: 'press',
|
|
213
|
+
args,
|
|
214
|
+
ctx,
|
|
215
|
+
act: () => engine.press(args, ctx),
|
|
216
|
+
describe: () => `Pressed ${args.key}`,
|
|
217
|
+
});
|
|
156
218
|
} catch (err) {
|
|
157
219
|
return errorResult(`Press failed: ${err.message}`);
|
|
158
220
|
}
|
|
@@ -173,8 +235,13 @@ module.exports = [
|
|
|
173
235
|
async handler(args, ctx) {
|
|
174
236
|
if (!args.target || typeof args.value !== 'string') return errorResult('target and value are required');
|
|
175
237
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
238
|
+
return await withEvidence({
|
|
239
|
+
action: 'select',
|
|
240
|
+
args,
|
|
241
|
+
ctx,
|
|
242
|
+
act: () => engine.select(args, ctx),
|
|
243
|
+
describe: () => `Selected "${args.value}" in ${args.target}`,
|
|
244
|
+
});
|
|
178
245
|
} catch (err) {
|
|
179
246
|
return errorResult(`Select failed: ${err.message}`);
|
|
180
247
|
}
|
|
@@ -193,7 +260,8 @@ module.exports = [
|
|
|
193
260
|
try {
|
|
194
261
|
const { result } = await engine.evaluate(args, ctx);
|
|
195
262
|
const output = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
|
196
|
-
return textResult(
|
|
263
|
+
return textResult(delta.clipOutput(output, OUTPUT_MAX_CHARS,
|
|
264
|
+
'Return less from the script — slice/filter/count in-page instead.') || '(no result)');
|
|
197
265
|
} catch (err) {
|
|
198
266
|
return errorResult(`Eval failed: ${err.message}`);
|
|
199
267
|
}
|
|
@@ -237,12 +305,98 @@ module.exports = [
|
|
|
237
305
|
try {
|
|
238
306
|
const { result } = await engine.passthrough(args, ctx);
|
|
239
307
|
const output = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
|
240
|
-
return textResult(
|
|
308
|
+
return textResult(delta.clipOutput(output, OUTPUT_MAX_CHARS) || 'ok');
|
|
241
309
|
} catch (err) {
|
|
242
310
|
return errorResult(`cli failed: ${err.message}`);
|
|
243
311
|
}
|
|
244
312
|
},
|
|
245
313
|
},
|
|
314
|
+
{
|
|
315
|
+
name: 'dialog',
|
|
316
|
+
description: 'Resolve a JavaScript dialog (alert/confirm/prompt) that is blocking the page: accept or dismiss, with optional text for prompts. Action results warn when a dialog appears — resolve it here before any other action will work.',
|
|
317
|
+
inputSchema: {
|
|
318
|
+
type: 'object',
|
|
319
|
+
properties: {
|
|
320
|
+
action: { type: 'string', enum: ['accept', 'dismiss'], description: 'accept = OK, dismiss = Cancel' },
|
|
321
|
+
text: { type: 'string', description: 'Text to enter into a prompt() before accepting' },
|
|
322
|
+
...sessionProperty,
|
|
323
|
+
},
|
|
324
|
+
required: ['action'],
|
|
325
|
+
},
|
|
326
|
+
async handler(args, ctx) {
|
|
327
|
+
if (args.action !== 'accept' && args.action !== 'dismiss') return errorResult('action must be "accept" or "dismiss"');
|
|
328
|
+
try {
|
|
329
|
+
const command = ['dialog', args.action];
|
|
330
|
+
if (typeof args.text === 'string' && args.action === 'accept') command.push(args.text);
|
|
331
|
+
const result = await engine.passthrough({ ...args, args: command }, ctx);
|
|
332
|
+
return actionResult(
|
|
333
|
+
`Dialog ${args.action === 'accept' ? 'accepted' : 'dismissed'}${args.text ? ` with text "${args.text}"` : ''}`,
|
|
334
|
+
await delta.describeAfter(result.session, ctx),
|
|
335
|
+
);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
return errorResult(`dialog failed: ${err.message}`);
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
name: 'tab',
|
|
343
|
+
description: 'Manage the session\'s browser tabs: list them (stable ids like "t2"), switch to one, open a new one, or close one. Links with target=_blank and window.open popups become tabs automatically and are auto-focused.',
|
|
344
|
+
inputSchema: {
|
|
345
|
+
type: 'object',
|
|
346
|
+
properties: {
|
|
347
|
+
action: { type: 'string', enum: ['list', 'switch', 'new', 'close'], description: 'What to do' },
|
|
348
|
+
id: { type: 'string', description: 'Tab id from list, e.g. "t2" — required for switch, optional for close (defaults to the active tab)' },
|
|
349
|
+
...sessionProperty,
|
|
350
|
+
},
|
|
351
|
+
required: ['action'],
|
|
352
|
+
},
|
|
353
|
+
async handler(args, ctx) {
|
|
354
|
+
try {
|
|
355
|
+
let command;
|
|
356
|
+
// Bare `tab` lists — and stays allowed in the desktop app, where
|
|
357
|
+
// switching targets is refused (a session IS its surface there).
|
|
358
|
+
if (args.action === 'list') command = ['tab'];
|
|
359
|
+
else if (args.action === 'new') command = ['tab', 'new'];
|
|
360
|
+
else if (args.action === 'switch') {
|
|
361
|
+
if (!args.id) return errorResult('id is required for switch (run {action: "list"} first)');
|
|
362
|
+
command = ['tab', args.id];
|
|
363
|
+
} else if (args.action === 'close') command = ['tab', 'close', ...(args.id ? [args.id] : [])];
|
|
364
|
+
else return errorResult('action must be one of: list, switch, new, close');
|
|
365
|
+
const { result, session } = await engine.passthrough({ ...args, args: command }, ctx);
|
|
366
|
+
const output = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
|
367
|
+
if (args.action === 'list') return textResult(delta.clipOutput(output, OUTPUT_MAX_CHARS) || 'ok');
|
|
368
|
+
return actionResult(output || `tab ${args.action} ok`, await delta.describeAfter(session, ctx));
|
|
369
|
+
} catch (err) {
|
|
370
|
+
return errorResult(`tab failed: ${err.message}`);
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: 'console',
|
|
376
|
+
description: 'Read the page\'s console logs and uncaught errors captured for this session. Check this after actions that misbehave — a page that looks stuck is often throwing.',
|
|
377
|
+
inputSchema: {
|
|
378
|
+
type: 'object',
|
|
379
|
+
properties: {
|
|
380
|
+
clear: { type: 'boolean', description: 'Clear the captured logs and errors after reading' },
|
|
381
|
+
...sessionProperty,
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
async handler(args, ctx) {
|
|
385
|
+
try {
|
|
386
|
+
const flags = args.clear ? ['--clear'] : [];
|
|
387
|
+
const logs = await engine.passthrough({ ...args, args: ['console', ...flags] }, ctx);
|
|
388
|
+
const errors = await engine.passthrough({ ...args, args: ['errors', ...flags] }, ctx);
|
|
389
|
+
const asText = (value) => (typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value ?? ''));
|
|
390
|
+
const body = [
|
|
391
|
+
`Console:\n${delta.clipOutput(asText(logs.result), 8_000) || '(empty)'}`,
|
|
392
|
+
`Page errors:\n${delta.clipOutput(asText(errors.result), 6_000) || '(none)'}`,
|
|
393
|
+
].join('\n\n');
|
|
394
|
+
return textResult(body);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
return errorResult(`console failed: ${err.message}`);
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
},
|
|
246
400
|
{
|
|
247
401
|
name: 'close',
|
|
248
402
|
description: 'End the browser session. Headless browsers shut down; the visible desktop surface stays open for the user. A fresh session starts on the next browser call.',
|
|
@@ -77,6 +77,13 @@ function buildActionDescriptor(toolName, args = {}) {
|
|
|
77
77
|
return `Browser CLI${quoteIfPresent(Array.isArray(args.args) ? args.args.join(' ') : '')}`;
|
|
78
78
|
case 'browser_close':
|
|
79
79
|
return 'Close browser';
|
|
80
|
+
case 'cua':
|
|
81
|
+
case 'browser_cua': {
|
|
82
|
+
const action = String(args.action || '');
|
|
83
|
+
if (action === 'screenshot') return 'Take visible browser screenshot';
|
|
84
|
+
if (action === 'type') return args.text ? `Type ${clipText(args.text, 40)} in browser` : 'Type in browser';
|
|
85
|
+
return action ? `Browser ${action.replace(/_/g, ' ')} (coordinates)` : 'Browser computer-use action';
|
|
86
|
+
}
|
|
80
87
|
case 'cua_click':
|
|
81
88
|
return 'Click browser coordinates';
|
|
82
89
|
case 'cua_double_click':
|
|
@@ -93,6 +100,12 @@ function buildActionDescriptor(toolName, args = {}) {
|
|
|
93
100
|
return 'Drag in browser';
|
|
94
101
|
case 'cua_screenshot':
|
|
95
102
|
return 'Take visible browser screenshot';
|
|
103
|
+
case 'browser_dialog':
|
|
104
|
+
return args.action === 'dismiss' ? 'Dismiss browser dialog' : 'Accept browser dialog';
|
|
105
|
+
case 'browser_tab':
|
|
106
|
+
return `Browser tab ${clipText(String(args.action || 'list'), 20)}`;
|
|
107
|
+
case 'browser_console':
|
|
108
|
+
return 'Read browser console';
|
|
96
109
|
case 'record_start':
|
|
97
110
|
return `Start browser recording${quoteIfPresent(args.name)}`;
|
|
98
111
|
case 'record_stop':
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-app-crash-loop-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, 'user');
|
|
11
|
+
process.env.AMALGM_HOME = path.join(tempRoot, 'home');
|
|
12
|
+
process.env.AMALGM_RUNTIME_LABEL = 'test';
|
|
13
|
+
process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
|
|
14
|
+
|
|
15
|
+
const { getApp, saveApps } = require('../apps/store');
|
|
16
|
+
const { APP_RESTART_POLICY, startApp, stopApp } = require('../apps/supervisor');
|
|
17
|
+
const { APPS_DIR } = require('../config');
|
|
18
|
+
|
|
19
|
+
function waitFor(predicate, label, timeoutMs = 10_000) {
|
|
20
|
+
const deadline = Date.now() + timeoutMs;
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const tick = async () => {
|
|
23
|
+
try {
|
|
24
|
+
if (await predicate()) return resolve();
|
|
25
|
+
} catch {}
|
|
26
|
+
if (Date.now() >= deadline) return reject(new Error(`Timed out waiting for ${label}`));
|
|
27
|
+
setTimeout(tick, 50);
|
|
28
|
+
};
|
|
29
|
+
tick();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('a crash-looping app is parked as degraded instead of restarting forever', async (t) => {
|
|
34
|
+
APP_RESTART_POLICY.initialDelayMs = 50;
|
|
35
|
+
APP_RESTART_POLICY.maxDelayMs = 100;
|
|
36
|
+
APP_RESTART_POLICY.maxCrashes = 3;
|
|
37
|
+
|
|
38
|
+
const appCwd = fs.mkdtempSync(path.join(tempRoot, 'crasher-'));
|
|
39
|
+
const script = path.join(appCwd, 'crash.js');
|
|
40
|
+
fs.writeFileSync(script, "console.log('boom before dying'); process.exit(1);\n");
|
|
41
|
+
|
|
42
|
+
saveApps({
|
|
43
|
+
version: 1,
|
|
44
|
+
apps: [{
|
|
45
|
+
id: 'app-crash-loop',
|
|
46
|
+
kind: 'app',
|
|
47
|
+
name: 'Crash Loop',
|
|
48
|
+
cwd: appCwd,
|
|
49
|
+
port: 0,
|
|
50
|
+
startCommand: `"${process.execPath}" "${script}"`,
|
|
51
|
+
appRef: 'crashloop',
|
|
52
|
+
publicUrl: 'https://crashloop.apps.amalgm.ai/',
|
|
53
|
+
dnsConnected: false,
|
|
54
|
+
autostart: true,
|
|
55
|
+
keepAlive: true,
|
|
56
|
+
desiredState: 'running',
|
|
57
|
+
status: 'registered',
|
|
58
|
+
pid: null,
|
|
59
|
+
}],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
t.after(() => stopApp('app-crash-loop').catch(() => {}));
|
|
63
|
+
|
|
64
|
+
await startApp('app-crash-loop');
|
|
65
|
+
await waitFor(() => getApp('app-crash-loop')?.status === 'degraded', 'degraded status');
|
|
66
|
+
|
|
67
|
+
const app = getApp('app-crash-loop');
|
|
68
|
+
assert.equal(app.status, 'degraded');
|
|
69
|
+
assert.equal(app.pid, null);
|
|
70
|
+
assert.match(app.error, /automatic restarts paused/);
|
|
71
|
+
|
|
72
|
+
// Degraded means parked: no further restarts should happen on their own.
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
74
|
+
assert.equal(getApp('app-crash-loop').status, 'degraded');
|
|
75
|
+
|
|
76
|
+
// App output landed in the app's own log file, not the runtime log.
|
|
77
|
+
const logFile = path.join(APPS_DIR, 'logs', 'app-crash-loop.log');
|
|
78
|
+
await waitFor(() => fs.existsSync(logFile), 'app log file');
|
|
79
|
+
assert.match(fs.readFileSync(logFile, 'utf8'), /boom before dying/);
|
|
80
|
+
|
|
81
|
+
// An explicit start resets the crash budget and tries again.
|
|
82
|
+
await startApp('app-crash-loop');
|
|
83
|
+
const restarted = getApp('app-crash-loop');
|
|
84
|
+
assert.equal(restarted.status, 'running');
|
|
85
|
+
});
|