@reticlehq/server 3.8.1 → 3.9.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 +201 -104
- package/NOTICE +5 -0
- package/README.md +1 -1
- package/bin/launch.js +13 -0
- package/bin/package.json +3 -0
- package/bin/reticle.js +66 -8
- package/dist/chromium-launch-options.d.ts +25 -0
- package/dist/chromium-launch-options.js +21 -1
- package/dist/command/cli/adhoc-verdict.js +34 -7
- package/dist/command/cli/answers/gate-exit.d.ts +6 -2
- package/dist/command/cli/answers/gate-exit.js +12 -2
- package/dist/command/cli/ci-hint.d.ts +44 -0
- package/dist/command/cli/ci-hint.js +72 -0
- package/dist/command/cli/cli-doctor.d.ts +6 -1
- package/dist/command/cli/cli-doctor.js +45 -4
- package/dist/command/cli/cli-flow-commands.d.ts +7 -1
- package/dist/command/cli/cli-flow-commands.js +39 -17
- package/dist/command/cli/cli-kill.d.ts +21 -0
- package/dist/command/cli/cli-kill.js +46 -3
- package/dist/command/cli/cli-parse.d.ts +5 -2
- package/dist/command/cli/cli-parse.js +10 -5
- package/dist/command/cli/cli-usage.js +3 -2
- package/dist/command/cli/cli-verify.d.ts +6 -1
- package/dist/command/cli/cli-verify.js +32 -13
- package/dist/command/cli/doctor/browser/chromium-hint.d.ts +6 -1
- package/dist/command/cli/doctor/browser/chromium-hint.js +11 -5
- package/dist/command/cli/doctor/doctor-fix.d.ts +44 -0
- package/dist/command/cli/doctor/doctor-fix.js +89 -0
- package/dist/command/cli/doctor/doctor-launcher-line.d.ts +23 -0
- package/dist/command/cli/doctor/doctor-launcher-line.js +60 -0
- package/dist/command/cli/doctor/doctor-rows.d.ts +4 -0
- package/dist/command/cli/doctor/doctor-rows.js +4 -0
- package/dist/command/cli/doctor-repairs.d.ts +64 -0
- package/dist/command/cli/doctor-repairs.js +214 -0
- package/dist/command/cli/drive/drive-command.js +3 -7
- package/dist/command/cli/first-use-checks.d.ts +41 -0
- package/dist/command/cli/first-use-checks.js +127 -0
- package/dist/command/cli/first-verdict-node.d.ts +3 -0
- package/dist/command/cli/first-verdict-node.js +189 -0
- package/dist/command/cli/first-verdict.d.ts +126 -0
- package/dist/command/cli/first-verdict.js +267 -0
- package/dist/command/cli/launch/sdk-version-match.d.ts +3 -2
- package/dist/command/cli/launch/sdk-version-match.js +3 -2
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +6 -5
- package/dist/command/cli/mcp-command.js +13 -4
- package/dist/command/cli/ports/port-holder.d.ts +2 -0
- package/dist/command/cli/ports/port-holder.js +11 -3
- package/dist/command/cli/setup-mcp-cli.d.ts +27 -1
- package/dist/command/cli/setup-mcp-cli.js +98 -23
- package/dist/command/cli/status/status-lines.d.ts +1 -0
- package/dist/command/cli/status/status-lines.js +50 -17
- package/dist/command/cli/status-fields.js +17 -3
- package/dist/command/cli/try-command.d.ts +7 -0
- package/dist/command/cli/try-command.js +11 -3
- package/dist/command/cli.js +70 -33
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +3 -1
- package/dist/command/daemon/binding/daemon-status-probe.js +4 -2
- package/dist/command/daemon/daemon-argv.d.ts +9 -0
- package/dist/command/daemon/daemon-argv.js +11 -0
- package/dist/command/daemon/daemon.d.ts +10 -3
- package/dist/command/daemon/daemon.js +17 -6
- package/dist/command/daemon/lifetime/daemon-exit.d.ts +24 -0
- package/dist/command/daemon/lifetime/daemon-exit.js +39 -0
- package/dist/command/daemon/lifetime/death-cause.d.ts +15 -0
- package/dist/command/daemon/lifetime/death-cause.js +130 -0
- package/dist/command/daemon/lifetime/heartbeat.d.ts +7 -15
- package/dist/command/daemon/lifetime/heartbeat.js +4 -13
- package/dist/command/daemon/port-state.d.ts +14 -0
- package/dist/command/daemon/port-state.js +31 -0
- package/dist/command/setup/agent-configs.d.ts +2 -0
- package/dist/command/setup/agent-configs.js +42 -15
- package/dist/command/setup/agent-next-steps.d.ts +77 -0
- package/dist/command/setup/agent-next-steps.js +284 -0
- package/dist/command/setup/agent-writer.js +11 -6
- package/dist/command/setup/init/init-host.js +2 -0
- package/dist/command/setup/init/init-runtime.d.ts +6 -1
- package/dist/command/setup/init/init-runtime.js +57 -21
- package/dist/command/setup/mcp-launcher.d.ts +3 -0
- package/dist/command/setup/mcp-launcher.js +11 -0
- package/dist/command/setup/setup-command.js +30 -12
- package/dist/command/setup/setup-mcp.d.ts +8 -0
- package/dist/command/setup/setup-mcp.js +47 -20
- package/dist/command/setup/start-dev-server.d.ts +8 -0
- package/dist/command/setup/start-dev-server.js +43 -0
- package/dist/command/version/identity/server-version.d.ts +2 -0
- package/dist/command/version/identity/server-version.js +2 -0
- package/dist/command/version/version-skew.d.ts +7 -0
- package/dist/command/version/version-skew.js +13 -2
- package/dist/features/license/license.d.ts +11 -71
- package/dist/features/license/license.js +43 -136
- package/dist/features/visual/desktop-capture.d.ts +33 -0
- package/dist/features/visual/desktop-capture.js +94 -0
- package/dist/features/visual/step-shots.js +139 -35
- package/dist/features/visual/visual-tools.d.ts +0 -18
- package/dist/features/visual/visual-tools.js +2 -81
- package/dist/import-graph.js +5 -6
- package/dist/index.d.ts +3 -1
- package/dist/index.js +14 -5
- package/dist/judgement/runs/run-ledger.d.ts +7 -5
- package/dist/judgement/runs/run-ledger.js +98 -69
- package/dist/judgement/runs/verification-sync.d.ts +6 -3
- package/dist/judgement/runs/verification-sync.js +21 -10
- package/dist/language/flows/flow-store-for-session.js +7 -5
- package/dist/language/flows/recording/tape/recordings.d.ts +2 -13
- package/dist/language/flows/recording/tape/recordings.js +41 -44
- package/dist/language/flows/replay.d.ts +17 -7
- package/dist/language/flows/replay.js +95 -44
- package/dist/launch-chromium.d.ts +1 -0
- package/dist/launch-chromium.js +49 -12
- package/dist/memory/cloud/sync-screenshots.js +6 -1
- package/dist/portal/bridge/bridge.d.ts +13 -4
- package/dist/portal/bridge/bridge.js +10 -9
- package/dist/portal/input/drive-error.d.ts +6 -0
- package/dist/portal/input/drive-error.js +9 -0
- package/dist/portal/input/network-mock.js +3 -2
- package/dist/portal/input/real-input-press.d.ts +34 -0
- package/dist/portal/input/real-input-press.js +150 -0
- package/dist/portal/input/real-input.d.ts +21 -23
- package/dist/portal/input/real-input.js +153 -194
- package/dist/portal/input/viewport-tools.js +6 -3
- package/dist/portal/pool/browser-pool.d.ts +5 -1
- package/dist/portal/pool/browser-pool.js +32 -38
- package/dist/portal/pool/context-permissions.d.ts +20 -17
- package/dist/portal/pool/context-permissions.js +63 -1
- package/dist/portal/pool/csp-widen.d.ts +62 -0
- package/dist/portal/pool/csp-widen.js +132 -0
- package/dist/portal/pool/lease-page-error.d.ts +29 -0
- package/dist/portal/pool/lease-page-error.js +18 -0
- package/dist/portal/pool/page-error-journal.d.ts +57 -0
- package/dist/portal/pool/page-error-journal.js +181 -0
- package/dist/portal/pool/playwright-launcher.js +21 -8
- package/dist/portal/pool/pool-contract.d.ts +22 -0
- package/dist/portal/pool/pool-lease.d.ts +35 -2
- package/dist/portal/pool/pool-lease.js +23 -0
- package/dist/portal/pool/zero-install.js +69 -1
- package/dist/portal/session/dev-server/dev-command.d.ts +8 -0
- package/dist/portal/session/dev-server/dev-command.js +56 -8
- package/dist/portal/session/no-session-diagnosis.js +10 -3
- package/dist/portal/session/no-session-next-action.d.ts +12 -0
- package/dist/portal/session/no-session-next-action.js +44 -4
- package/dist/portal/session/no-session-watch.js +3 -1
- package/dist/portal/session/page-commands/undeclared-command.d.ts +9 -3
- package/dist/portal/session/page-commands/undeclared-command.js +16 -7
- package/dist/portal/session/recorded-gaps.d.ts +1 -1
- package/dist/portal/session/recorded-gaps.js +5 -2
- package/dist/portal/session/session-manager.js +40 -21
- package/dist/portal/session/session.js +4 -3
- package/dist/portal/session/zero-install-first-call.d.ts +20 -1
- package/dist/portal/session/zero-install-first-call.js +34 -2
- package/dist/start-wiring.d.ts +29 -2
- package/dist/start-wiring.js +73 -9
- package/dist/surface/mcp/mcp-proxy.js +10 -2
- package/dist/surface/mcp/mcp.js +5 -3
- package/dist/surface/mcp/peer/agents-connected.d.ts +6 -0
- package/dist/surface/mcp/peer/agents-connected.js +58 -0
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +17 -5
- package/dist/surface/tools/act/act-preflight.js +37 -17
- package/dist/surface/tools/act/act-target.js +3 -1
- package/dist/surface/tools/act/after-match.d.ts +7 -0
- package/dist/surface/tools/act/after-match.js +33 -0
- package/dist/surface/tools/act/resolve-target.d.ts +2 -0
- package/dist/surface/tools/act/resolve-target.js +35 -8
- package/dist/surface/tools/act/sequence-grade.d.ts +3 -1
- package/dist/surface/tools/act/sequence-grade.js +8 -1
- package/dist/surface/tools/act-sequence-tool.js +3 -0
- package/dist/surface/tools/act-tools.js +12 -12
- package/dist/surface/tools/assert/assert-verdict.js +28 -1
- package/dist/surface/tools/browser-tools.js +75 -7
- package/dist/surface/tools/dynamic-tools.js +2 -1
- package/dist/surface/tools/empty-session-list.d.ts +2 -0
- package/dist/surface/tools/empty-session-list.js +4 -2
- package/dist/surface/tools/error-recovery.js +43 -22
- package/dist/surface/tools/invoke-tool.js +27 -20
- package/dist/surface/tools/lease-hint.d.ts +7 -0
- package/dist/surface/tools/lease-hint.js +10 -3
- package/dist/surface/tools/lease-readiness.d.ts +21 -0
- package/dist/surface/tools/lease-readiness.js +47 -0
- package/dist/surface/tools/lease-seed.d.ts +2 -0
- package/dist/surface/tools/lease-seed.js +2 -1
- package/dist/surface/tools/lease-tools.js +43 -15
- package/dist/surface/tools/real-input-attempt.js +70 -31
- package/dist/surface/tools/repeat-hint.d.ts +40 -0
- package/dist/surface/tools/repeat-hint.js +171 -0
- package/dist/surface/tools/run-report.d.ts +2 -2
- package/dist/surface/tools/run-report.js +27 -15
- package/dist/surface/tools/tool-kit.d.ts +12 -0
- package/dist/surface/tools/tool-kit.js +7 -0
- package/dist/surface/tools/tools.js +26 -13
- package/dist/telemetry/argument-shape.js +16 -7
- package/dist/telemetry/cli-telemetry.d.ts +10 -1
- package/dist/telemetry/cli-telemetry.js +15 -0
- package/dist/telemetry/init-telemetry.d.ts +2 -1
- package/dist/telemetry/init-telemetry.js +47 -2
- package/dist/telemetry/install-trace.js +23 -4
- package/dist/telemetry/onboarding-funnel.d.ts +19 -2
- package/dist/telemetry/onboarding-funnel.js +25 -3
- package/dist/telemetry/proxy-telemetry.d.ts +2 -1
- package/dist/telemetry/proxy-telemetry.js +2 -2
- package/dist/telemetry/session-metrics.d.ts +1 -1
- package/dist/telemetry/session-metrics.js +67 -27
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +60 -28
- package/dist/telemetry/tool-refused.d.ts +2 -2
- package/dist/telemetry/tool-refused.js +9 -2
- package/docs/architecture.md +3 -6
- package/docs/cli/doctor.mdx +41 -2
- package/docs/cli/help.mdx +4 -2
- package/docs/cli/license.mdx +4 -5
- package/docs/cli/setup.mdx +13 -2
- package/docs/cli/status.mdx +4 -0
- package/docs/cli.mdx +1 -1
- package/docs/deploy-checks.md +25 -0
- package/docs/desktop.mdx +1 -1
- package/docs/enterprise.md +15 -20
- package/docs/environment.mdx +1 -0
- package/docs/first-drive.md +1 -1
- package/docs/install-manual.mdx +16 -1
- package/docs/packages/engine.mdx +1 -1
- package/docs/packages/react.mdx +14 -2
- package/docs/packages/server.mdx +1 -1
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages/test.mdx +1 -1
- package/docs/packages/vite-plugin.mdx +1 -0
- package/docs/packages.mdx +1 -1
- package/docs/platform-integration.md +1 -1
- package/docs/skill.md +1 -1
- package/docs/telemetry.md +4 -2
- package/package.json +14 -10
- package/LICENSE-ENTERPRISE +0 -37
- package/dist/features/ee/audit-log.d.ts +0 -14
- package/dist/features/ee/audit-log.js +0 -11
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import * as production from '../../portal/production/production-activation.js';
|
|
13
13
|
import { z } from 'zod';
|
|
14
14
|
import { leaseNotConnectedHint, leaseReaderFailedHint, leaseStillLoadingHint, } from './lease-hint.js';
|
|
15
|
-
import { connectOrInject, moveReusedLease, notReadyReason, probeLeaseAlive, } from './lease-readiness.js';
|
|
15
|
+
import { LEASE_READY_TIMEOUT_MS, awaitLeaseAnswering, connectOrInject, connectWaitUntil, moveReusedLease, notReadyReason, probeLeaseAlive, } from './lease-readiness.js';
|
|
16
16
|
import { probeSdkMarker } from './gaps/sdk-marker-probe.js';
|
|
17
17
|
import { observeWebDocument } from '../../portal/session/dev-server/served-document.js';
|
|
18
18
|
import { diagnoseObservedWebCsp } from '@reticlehq/init';
|
|
@@ -25,7 +25,8 @@ import { LeaseNotReadyReason, RETICLE_URL_PARAM, RETICLE_DEFAULT_PORT, SeedStora
|
|
|
25
25
|
import { HudVisibility, ReticleTool, SESSION_HEALTH } from '@reticlehq/core';
|
|
26
26
|
import { HOST_STUB_NOTE } from './tool-kit.js';
|
|
27
27
|
import { PoolAtCapacityError } from '../../portal/pool/browser-pool.js';
|
|
28
|
-
import { asString } from '@reticlehq/core';
|
|
28
|
+
import { asNumber, asString } from '@reticlehq/core';
|
|
29
|
+
import { timeoutMsSchema } from './args/numeric-bounds.js';
|
|
29
30
|
import { chromiumPreflightRefusal } from '../../command/cli/doctor/browser/chromium-hint.js';
|
|
30
31
|
import { LEASE_PERMISSIONS_ARG, hintOf, notificationReadBack, parseLeasePermissions, permissionRefusal, refuseRegrant, } from './lease-permissions.js';
|
|
31
32
|
import { LEASE_EMULATE_ARG, leaseEmulationOf } from '../../portal/input/viewport-tools.js';
|
|
@@ -90,7 +91,7 @@ function alreadyHeldHint(sessionId, origin) {
|
|
|
90
91
|
return (`${ALREADY_HELD_LEASE} (${origin}): ${sessionId} — reused it rather than opening a second ` +
|
|
91
92
|
'tab, which would make every later call require sessionId. It is still signed in as whoever ' +
|
|
92
93
|
'used it last (cookies and storage come with it): release it first for a fresh context or ' +
|
|
93
|
-
'another identity.');
|
|
94
|
+
'another identity, or pass fresh: true to open a separate tab and keep this one.');
|
|
94
95
|
}
|
|
95
96
|
/**
|
|
96
97
|
* Append Reticle identity params (__reticle_session, optional __reticle_project) to a URL so the app's own SDK
|
|
@@ -259,6 +260,10 @@ export const LEASE_ACQUIRE_TOOL = {
|
|
|
259
260
|
.optional()
|
|
260
261
|
.describe('Stable project id to stamp on the leased tab so the agent can scope to it.'),
|
|
261
262
|
seedStorage: SeedStorageSchema.optional().describe('Initial storage state (local, session and cookies) to seed before the first navigation (e.g. `seedStorage: { local: { token: "..." } }` to start already authenticated).'),
|
|
263
|
+
fresh: z
|
|
264
|
+
.boolean()
|
|
265
|
+
.optional()
|
|
266
|
+
.describe('Open a separate tab even when this connection already holds a lease on the origin, and keep that one. For parallel work that shares one MCP connection (sub-agents). Pass the returned sessionId to every later call.'),
|
|
262
267
|
initScript: z
|
|
263
268
|
.string()
|
|
264
269
|
.optional()
|
|
@@ -278,6 +283,9 @@ export const LEASE_ACQUIRE_TOOL = {
|
|
|
278
283
|
permissions: LEASE_PERMISSIONS_ARG,
|
|
279
284
|
emulate: LEASE_EMULATE_ARG,
|
|
280
285
|
production: production.PRODUCTION_LEASE_ARG,
|
|
286
|
+
timeout_ms: timeoutMsSchema
|
|
287
|
+
.optional()
|
|
288
|
+
.describe(`How long to wait for the leased tab to connect and answer before answering ready:false with notReadyReason. Default ${String(LEASE_READY_TIMEOUT_MS)}. 0 looks once without waiting.`),
|
|
281
289
|
root: z
|
|
282
290
|
.string()
|
|
283
291
|
.optional()
|
|
@@ -288,7 +296,7 @@ export const LEASE_ACQUIRE_TOOL = {
|
|
|
288
296
|
url: z.string(),
|
|
289
297
|
ready: z
|
|
290
298
|
.boolean()
|
|
291
|
-
.describe('Whether the leased tab is connected AND
|
|
299
|
+
.describe('Whether the leased tab is connected AND answered a probe under sessionId, so the next call finds it. acquire waits for this, up to timeout_ms. It is NOT a claim that the app has hydrated, so an act can miss: make the first act reticle_act_and_wait with an `until`, or reticle_assert { action: "wait" }. Empty reads may mean not hydrated. False ⇒ read notReadyReason.'),
|
|
292
300
|
zeroInstall: z
|
|
293
301
|
.boolean()
|
|
294
302
|
.optional()
|
|
@@ -297,6 +305,10 @@ export const LEASE_ACQUIRE_TOOL = {
|
|
|
297
305
|
.string()
|
|
298
306
|
.optional()
|
|
299
307
|
.describe('Present when this leased tab runs an init script stubbing the bindings a desktop shell injects. Verdicts on this lease are about the app with that stub, not about the real shell.'),
|
|
308
|
+
cspWidenedFor: z
|
|
309
|
+
.string()
|
|
310
|
+
.optional()
|
|
311
|
+
.describe("Present when the lease added this origin (the daemon's WebSocket) to the page CSP connect-src, so the zero-install reader can connect. The rest of the page CSP applies as the server sent it."),
|
|
300
312
|
notReadyReason: z
|
|
301
313
|
.enum([
|
|
302
314
|
LeaseNotReadyReason.SDK_NEVER_DIALLED,
|
|
@@ -408,14 +420,17 @@ export const LEASE_ACQUIRE_TOOL = {
|
|
|
408
420
|
// an init script has to exist before the page's own scripts run, which a loaded page cannot
|
|
409
421
|
// do, and the resolve below then finds nothing and mints a fresh context with the stub. A
|
|
410
422
|
// lease of the other kind is left alone, exactly as it is when a window was asked for.
|
|
411
|
-
|
|
423
|
+
// `fresh` asks for a separate tab: the held one is neither reused nor released (#1932).
|
|
424
|
+
const fresh = true === args['fresh'];
|
|
425
|
+
const heldOn = (o) => fresh || o === undefined ? undefined : pool.leaseIdOnOrigin(o, deps.attachId);
|
|
426
|
+
const heldForStub = heldOn(origin);
|
|
412
427
|
if (initScript !== undefined &&
|
|
413
428
|
heldForStub !== undefined &&
|
|
414
429
|
pool.isHeaded(heldForStub) === headed &&
|
|
415
430
|
pool.hostStubScript?.(heldForStub) !== initScript) {
|
|
416
431
|
await pool.release(heldForStub);
|
|
417
432
|
}
|
|
418
|
-
const held =
|
|
433
|
+
const held = heldOn(origin);
|
|
419
434
|
const existing = held !== undefined && pool.isHeaded(held) === headed ? held : undefined;
|
|
420
435
|
// A context's certificate setting is fixed when it is made, so an explicit request for the
|
|
421
436
|
// other one cannot be answered by the held lease: handing it back would keep checks off after
|
|
@@ -475,6 +490,7 @@ export const LEASE_ACQUIRE_TOOL = {
|
|
|
475
490
|
// Asked of the pool, not remembered here: the stub was installed when THIS lease was
|
|
476
491
|
// minted, and the caller reusing it now may be seeing it for the first time.
|
|
477
492
|
...(true === pool.stubInstalled?.(existing) ? { hostStub: HOST_STUB_NOTE } : {}),
|
|
493
|
+
...cspWidenedOf(pool, existing),
|
|
478
494
|
...(alreadyOpen === undefined
|
|
479
495
|
? {}
|
|
480
496
|
: { preferExisting: { sessionId: alreadyOpen, note: PREFER_EXISTING_NOTE } }),
|
|
@@ -526,12 +542,18 @@ export const LEASE_ACQUIRE_TOOL = {
|
|
|
526
542
|
// Resolved rather than assumed: an app that names its own session registers under that name,
|
|
527
543
|
// and the id we hand back has to be the one the agent can actually drive.
|
|
528
544
|
let registeredId;
|
|
529
|
-
const
|
|
530
|
-
const waited = await production.guardedForProduction(pool, lease, activated);
|
|
531
|
-
const { ready, zeroInstall, stillLoading, readerFailure } = await connectOrInject(waited, () => {
|
|
545
|
+
const isRegistered = () => {
|
|
532
546
|
registeredId = resolveLeasedSessionId(deps.sessions, lease.sessionId);
|
|
533
547
|
return registeredId !== undefined;
|
|
534
|
-
}
|
|
548
|
+
};
|
|
549
|
+
const waitStartedAt = deps.now();
|
|
550
|
+
const deadline = waitStartedAt + (asNumber(args['timeout_ms']) ?? LEASE_READY_TIMEOUT_MS);
|
|
551
|
+
const waited = await production.guardedForProduction(pool, lease, activated);
|
|
552
|
+
const { ready: connected, zeroInstall, stillLoading, readerFailure, } = await connectOrInject(waited, isRegistered, connectWaitUntil(deadline, deps.now));
|
|
553
|
+
// Connected is a row in the sessions map. Ready is a reply from it, so the next call works.
|
|
554
|
+
const answerReason = connected
|
|
555
|
+
? await awaitLeaseAnswering(() => (isRegistered() ? deps.sessions.get(registeredId ?? lease.sessionId) : undefined), deadline, deps.now)
|
|
556
|
+
: undefined;
|
|
535
557
|
// Tell the pool the other name this lease answers to. Every later touch and release arrives
|
|
536
558
|
// under the id we are about to hand back, and the pool is keyed by the id it navigated with;
|
|
537
559
|
// without this the touches miss, the lease ages out despite continuous activity, and the
|
|
@@ -541,6 +563,7 @@ export const LEASE_ACQUIRE_TOOL = {
|
|
|
541
563
|
}
|
|
542
564
|
const effectiveSessionId = registeredId ?? lease.sessionId;
|
|
543
565
|
const session = deps.sessions.get(effectiveSessionId);
|
|
566
|
+
const ready = connected && answerReason === undefined;
|
|
544
567
|
if (validatedSeed !== undefined && session !== undefined) {
|
|
545
568
|
const failureReason = evaluateSeedPrecondition(url, session.url, validatedSeed, lease.navStatus);
|
|
546
569
|
if (failureReason !== undefined) {
|
|
@@ -552,18 +575,17 @@ export const LEASE_ACQUIRE_TOOL = {
|
|
|
552
575
|
if (readerFailure === undefined) {
|
|
553
576
|
tellWatchers(deps, projectId, AGENT_DRIVING_ELSEWHERE);
|
|
554
577
|
}
|
|
555
|
-
// ready means the SDK
|
|
578
|
+
// ready means the SDK answers — not that contracts match. Carry the skew warning on acquire
|
|
556
579
|
// so the agent does not learn it only after a CDP tool invents a closed page (#688).
|
|
557
|
-
const mintReason = readerFailure?.reason ??
|
|
558
|
-
notReadyReason(ready, session, LeaseNotReadyReason.SDK_NEVER_DIALLED);
|
|
580
|
+
const mintReason = readerFailure?.reason ?? (connected ? answerReason : LeaseNotReadyReason.SDK_NEVER_DIALLED);
|
|
559
581
|
const versionSkew = registeredId === undefined ? undefined : deps.sessions.get(registeredId)?.versionSkew;
|
|
560
582
|
let notReadyHint;
|
|
561
|
-
if (!
|
|
583
|
+
if (!connected) {
|
|
562
584
|
if (readerFailure !== undefined) {
|
|
563
585
|
notReadyHint = leaseReaderFailedHint(url, readerFailure);
|
|
564
586
|
}
|
|
565
587
|
else if (true === stillLoading) {
|
|
566
|
-
notReadyHint = leaseStillLoadingHint(url,
|
|
588
|
+
notReadyHint = leaseStillLoadingHint(url, deps.now() - waitStartedAt);
|
|
567
589
|
}
|
|
568
590
|
else if (activated !== undefined) {
|
|
569
591
|
notReadyHint = production.notActivatedHint(url);
|
|
@@ -596,6 +618,7 @@ export const LEASE_ACQUIRE_TOOL = {
|
|
|
596
618
|
...(versionSkew === undefined ? {} : { versionSkew }),
|
|
597
619
|
...production.summary(activated),
|
|
598
620
|
...(initScript === undefined ? {} : { hostStub: HOST_STUB_NOTE }),
|
|
621
|
+
...cspWidenedOf(pool, lease.sessionId),
|
|
599
622
|
...hintOf(notReadyHint, ready ? await notificationReadBack(pool, lease.sessionId, permissions) : undefined),
|
|
600
623
|
};
|
|
601
624
|
}
|
|
@@ -607,6 +630,11 @@ export const LEASE_ACQUIRE_TOOL = {
|
|
|
607
630
|
}
|
|
608
631
|
},
|
|
609
632
|
};
|
|
633
|
+
/** `cspWidenedFor` when the lease changed its page CSP: a change to the page is declared (#2016). */
|
|
634
|
+
function cspWidenedOf(pool, leaseId) {
|
|
635
|
+
const origin = pool.cspWidenedFor?.(leaseId);
|
|
636
|
+
return origin === undefined ? {} : { cspWidenedFor: origin };
|
|
637
|
+
}
|
|
610
638
|
/** The whole not-connected diagnosis, gathered and worded. Split out so the acquire path stays flat. */
|
|
611
639
|
async function notConnectedHint(deps, url, dialledUrl) {
|
|
612
640
|
const bridgePort = deps.bridgePort ?? RETICLE_DEFAULT_PORT;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* the base64 4/3 inflation factor applied, so the encoded payload always fits in one WebSocket
|
|
20
20
|
* frame.
|
|
21
21
|
*/
|
|
22
|
-
import { ActionType, DriveErrorCode, ElementState, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, InputMode, InputModeReason, ReticleCommand, TRANSPORT_LIMITS, } from '@reticlehq/core';
|
|
22
|
+
import { ActionType, clickInputProblem, DriveErrorCode, ElementState, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, InputMode, InputModeReason, ReticleCommand, TRANSPORT_LIMITS, } from '@reticlehq/core';
|
|
23
23
|
import { boxCenter, DriveError, isRealInputAction, unspellablePressReason, } from '../../portal/input/real-input.js';
|
|
24
24
|
import { assertDragNotDestructive, assertNotDestructive } from './act/act-danger.js';
|
|
25
25
|
import { assertNativeAllowed } from '../../portal/production/production-drive.js';
|
|
@@ -101,10 +101,12 @@ const UPLOAD_MAX_BYTES = Math.floor((TRANSPORT_LIMITS.MAX_MESSAGE_BYTES / (4 / 3
|
|
|
101
101
|
* through unchanged. `home` is injected so unit tests do not depend on the machine's real home.
|
|
102
102
|
*/
|
|
103
103
|
function expandUserPath(rawPath, home) {
|
|
104
|
-
if ('~' === rawPath)
|
|
104
|
+
if ('~' === rawPath) {
|
|
105
105
|
return home;
|
|
106
|
-
|
|
106
|
+
}
|
|
107
|
+
if (rawPath.startsWith('~/') || rawPath.startsWith('~\\')) {
|
|
107
108
|
return join(home, rawPath.slice(2));
|
|
109
|
+
}
|
|
108
110
|
return rawPath;
|
|
109
111
|
}
|
|
110
112
|
/** True when `real` is inside `root` after both have been realpath'd / resolved. */
|
|
@@ -132,15 +134,18 @@ async function readUploadRoots(projectRoot, fs, home) {
|
|
|
132
134
|
catch {
|
|
133
135
|
return [];
|
|
134
136
|
}
|
|
135
|
-
if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed))
|
|
137
|
+
if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed)) {
|
|
136
138
|
return [];
|
|
139
|
+
}
|
|
137
140
|
const listed = parsed['uploadRoots'];
|
|
138
|
-
if (!Array.isArray(listed))
|
|
141
|
+
if (!Array.isArray(listed)) {
|
|
139
142
|
return [];
|
|
143
|
+
}
|
|
140
144
|
const roots = [];
|
|
141
145
|
for (const entry of listed) {
|
|
142
|
-
if ('string' !== typeof entry || 0 === entry.trim().length)
|
|
146
|
+
if ('string' !== typeof entry || 0 === entry.trim().length) {
|
|
143
147
|
continue;
|
|
148
|
+
}
|
|
144
149
|
roots.push(resolve(expandUserPath(entry.trim(), home)));
|
|
145
150
|
}
|
|
146
151
|
return roots;
|
|
@@ -191,11 +196,13 @@ async function resolveUploadPath(rawPath, projectRoot, fs, uploadRoots, home) {
|
|
|
191
196
|
export async function rewriteUploadArgs(deps, action, innerArgs,
|
|
192
197
|
/** Injected for tests; production uses the process home directory. */
|
|
193
198
|
home = homedir()) {
|
|
194
|
-
if (action !== ActionType.UPLOAD)
|
|
199
|
+
if (action !== ActionType.UPLOAD) {
|
|
195
200
|
return innerArgs;
|
|
201
|
+
}
|
|
196
202
|
const rawPath = asString(innerArgs['path']);
|
|
197
|
-
if (rawPath === undefined)
|
|
198
|
-
return innerArgs;
|
|
203
|
+
if (rawPath === undefined) {
|
|
204
|
+
return innerArgs;
|
|
205
|
+
} // no path → browser handles as inline upload
|
|
199
206
|
// Project root is one level above .reticle/
|
|
200
207
|
const projectRoot = resolve(join(deps.reticleRoot, '..'));
|
|
201
208
|
const uploadRoots = await readUploadRoots(projectRoot, deps.fs, home);
|
|
@@ -236,14 +243,17 @@ function synthetic(reason) {
|
|
|
236
243
|
function performArgsOf(inner) {
|
|
237
244
|
const performArgs = {};
|
|
238
245
|
const value = asString(inner['value']);
|
|
239
|
-
if (value !== undefined)
|
|
246
|
+
if (value !== undefined) {
|
|
240
247
|
performArgs.value = value;
|
|
248
|
+
}
|
|
241
249
|
const text = asString(inner['text']);
|
|
242
|
-
if (text !== undefined)
|
|
250
|
+
if (text !== undefined) {
|
|
243
251
|
performArgs.text = text;
|
|
252
|
+
}
|
|
244
253
|
const key = asString(inner['key']);
|
|
245
|
-
if (key !== undefined)
|
|
254
|
+
if (key !== undefined) {
|
|
246
255
|
performArgs.key = key;
|
|
256
|
+
}
|
|
247
257
|
const modifiers = inner['modifiers'];
|
|
248
258
|
if (Array.isArray(modifiers)) {
|
|
249
259
|
performArgs.modifiers = modifiers.filter((m) => 'string' === typeof m);
|
|
@@ -251,9 +261,14 @@ function performArgsOf(inner) {
|
|
|
251
261
|
// A held key is the whole point of a hold-to-confirm control, and the synthetic path has honoured
|
|
252
262
|
// `holdMs` all along. Not forwarding it made the real keyboard the WEAKER of the two paths: it
|
|
253
263
|
// drove the key instantly and reported a hold that never held.
|
|
264
|
+
const button = asString(inner['button']);
|
|
265
|
+
if (button !== undefined) {
|
|
266
|
+
performArgs.button = button;
|
|
267
|
+
}
|
|
254
268
|
const holdMs = asNumber(inner['holdMs']);
|
|
255
|
-
if (holdMs !== undefined)
|
|
269
|
+
if (holdMs !== undefined) {
|
|
256
270
|
performArgs.holdMs = holdMs;
|
|
271
|
+
}
|
|
257
272
|
// `keys` and `code` are read but not forwarded: both are routed away by the caller — a multi-key
|
|
258
273
|
// sequence and an explicit physical code have no driver spelling — so carrying them into the
|
|
259
274
|
// provider would only invite it to guess at a gesture the caller did not ask for.
|
|
@@ -285,11 +300,13 @@ const ANCHOR_FIELDS = [
|
|
|
285
300
|
*/
|
|
286
301
|
async function pressOnLease(deps, session, ref, inner) {
|
|
287
302
|
const pool = deps.pool;
|
|
288
|
-
if (pool === undefined || !pool.hasKeyboard(session.id))
|
|
303
|
+
if (pool === undefined || !pool.hasKeyboard(session.id)) {
|
|
289
304
|
return undefined;
|
|
305
|
+
}
|
|
290
306
|
const unspellable = unspellablePressReason('', inner);
|
|
291
|
-
if (unspellable !== undefined)
|
|
307
|
+
if (unspellable !== undefined) {
|
|
292
308
|
return synthetic(unspellable);
|
|
309
|
+
}
|
|
293
310
|
// What the focused element is called, from the focus act's own report, so the saved step can
|
|
294
311
|
// find it again: the real keyboard reports no element of its own.
|
|
295
312
|
let anchor = {};
|
|
@@ -307,8 +324,9 @@ async function pressOnLease(deps, session, ref, inner) {
|
|
|
307
324
|
}
|
|
308
325
|
}
|
|
309
326
|
const performed = await pool.pressLease(session.id, performArgsOf(inner));
|
|
310
|
-
if (performed?.performed !== true)
|
|
327
|
+
if (performed?.performed !== true) {
|
|
311
328
|
return undefined;
|
|
329
|
+
}
|
|
312
330
|
return {
|
|
313
331
|
result: {
|
|
314
332
|
...anchor,
|
|
@@ -382,15 +400,17 @@ async function hoverForReal(deps, session, ref, inner, provider) {
|
|
|
382
400
|
if (box === undefined) {
|
|
383
401
|
throw new Error(HOVER_NEEDS_POINTER_MSG);
|
|
384
402
|
}
|
|
385
|
-
if (!inView)
|
|
403
|
+
if (!inView) {
|
|
386
404
|
throw new Error(HOVER_OFF_VIEWPORT_MSG);
|
|
405
|
+
}
|
|
387
406
|
if (providerReady && provider !== undefined) {
|
|
388
407
|
try {
|
|
389
408
|
const performed = await provider.perform(session.url, ActionType.HOVER, box, {});
|
|
390
409
|
// Hover always has a resolved box, so there is always a center to report: the provider's own
|
|
391
410
|
// reading, or the box's if it reported none. Both are the same point, not a fabricated one.
|
|
392
|
-
if (performed.performed)
|
|
411
|
+
if (performed.performed) {
|
|
393
412
|
return hoverSucceeded(performed.center ?? boxCenter(box), scrolled);
|
|
413
|
+
}
|
|
394
414
|
}
|
|
395
415
|
catch {
|
|
396
416
|
// A provider error used to fall back to synthetic dispatch. For hover that is a lie.
|
|
@@ -428,8 +448,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
428
448
|
if (provider === undefined) {
|
|
429
449
|
if (action === ActionType.PRESS) {
|
|
430
450
|
const leased = await pressOnLease(deps, session, ref, inner);
|
|
431
|
-
if (leased !== undefined)
|
|
451
|
+
if (leased !== undefined) {
|
|
432
452
|
return leased;
|
|
453
|
+
}
|
|
433
454
|
}
|
|
434
455
|
// Silent by default: with no provider EVERY action is synthetic, and a reason on all of them is
|
|
435
456
|
// noise on the most-used tool in the product. But an agent that passed native:true asked a
|
|
@@ -441,11 +462,13 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
441
462
|
// one was asked for, instead of a different gesture being sent. See `unspellablePressReason`.
|
|
442
463
|
if (action === ActionType.PRESS) {
|
|
443
464
|
const unspellable = unspellablePressReason(ref, inner);
|
|
444
|
-
if (unspellable !== undefined)
|
|
465
|
+
if (unspellable !== undefined) {
|
|
445
466
|
return synthetic(unspellable);
|
|
467
|
+
}
|
|
446
468
|
}
|
|
447
|
-
if (!isRealInputAction(action))
|
|
448
|
-
return synthetic(InputModeReason.NOT_POINTER);
|
|
469
|
+
if (!isRealInputAction(action)) {
|
|
470
|
+
return synthetic(InputModeReason.NOT_POINTER);
|
|
471
|
+
} // fill/type stay synthetic
|
|
449
472
|
// "Don't click, run the code": a click/dblclick runs the occlusion-honest SYNTHETIC path by default
|
|
450
473
|
// even with a provider configured — no coordinate gesture to be intercepted by the HUD or missed
|
|
451
474
|
// off-screen. Opt into a trusted native click with args.native:true (file pickers, clipboard,
|
|
@@ -453,6 +476,13 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
453
476
|
if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && !askedForNative) {
|
|
454
477
|
return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
|
|
455
478
|
}
|
|
479
|
+
// The page refuses the same arguments on the synthetic path. A native click must not drop one.
|
|
480
|
+
if (action === ActionType.CLICK || action === ActionType.DBLCLICK) {
|
|
481
|
+
const problem = clickInputProblem(action, inner);
|
|
482
|
+
if (problem !== undefined) {
|
|
483
|
+
throw new Error(`cannot ${action}: ${problem}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
456
486
|
// Under version skew CDP is unusable while DOM tools still work (#688). Surface the skew sentence
|
|
457
487
|
// rather than "page not correlated" / a silent provider-error fallback — those send the agent
|
|
458
488
|
// hunting a dead context that is not dead.
|
|
@@ -466,15 +496,17 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
466
496
|
if (action !== ActionType.PRESS && session.versionSkew !== undefined) {
|
|
467
497
|
throw new Error(session.versionSkew);
|
|
468
498
|
}
|
|
469
|
-
if (!(await provider.isAvailableFor(session.url)))
|
|
499
|
+
if (!(await provider.isAvailableFor(session.url))) {
|
|
470
500
|
return synthetic(InputModeReason.PAGE_NOT_CORRELATED);
|
|
501
|
+
}
|
|
471
502
|
const performArgs = performArgsOf(inner);
|
|
472
503
|
// A document key has no element of its own: skip INSPECT and the box entirely.
|
|
473
504
|
if (action === ActionType.PRESS) {
|
|
474
505
|
try {
|
|
475
506
|
const performed = await provider.perform(session.url, action, PRESS_HAS_NO_BOX, performArgs);
|
|
476
|
-
if (!performed.performed)
|
|
507
|
+
if (!performed.performed) {
|
|
477
508
|
return synthetic(InputModeReason.PROVIDER_DECLINED);
|
|
509
|
+
}
|
|
478
510
|
// No `center`: a key addresses focus, not a point. The placeholder's zero happened nowhere.
|
|
479
511
|
//
|
|
480
512
|
// `heldMs` IS forwarded, and it is the whole reason this object is not just the three fields
|
|
@@ -521,13 +553,15 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
521
553
|
let box = asBox(inspected);
|
|
522
554
|
// A box outside the viewport after the reveal is a gesture that would land on nothing. The
|
|
523
555
|
// synthetic path scrolls and hit-tests on its own, so it is the honest answer.
|
|
524
|
-
if (box === undefined || !source.inView)
|
|
556
|
+
if (box === undefined || !source.inView) {
|
|
525
557
|
return synthetic(InputModeReason.ELEMENT_NOT_LOCATABLE);
|
|
558
|
+
}
|
|
526
559
|
let toBox;
|
|
527
560
|
if (action === ActionType.DRAG) {
|
|
528
561
|
const toRef = asString(inner['toRef']);
|
|
529
|
-
if (toRef === undefined)
|
|
562
|
+
if (toRef === undefined) {
|
|
530
563
|
return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
|
|
564
|
+
}
|
|
531
565
|
const target = await inspectRevealed(deps, session, toRef);
|
|
532
566
|
const targetInspected = target.inspected;
|
|
533
567
|
// A drag is judged on BOTH ends: dropping onto "Trash" is destructive however innocent the
|
|
@@ -549,12 +583,14 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
549
583
|
}
|
|
550
584
|
}
|
|
551
585
|
}
|
|
552
|
-
if (toBox !== undefined)
|
|
586
|
+
if (toBox !== undefined) {
|
|
553
587
|
performArgs.toBox = toBox;
|
|
588
|
+
}
|
|
554
589
|
try {
|
|
555
590
|
const performed = await provider.perform(session.url, action, box, performArgs);
|
|
556
|
-
if (!performed.performed)
|
|
591
|
+
if (!performed.performed) {
|
|
557
592
|
return synthetic(InputModeReason.PROVIDER_DECLINED);
|
|
593
|
+
}
|
|
558
594
|
return {
|
|
559
595
|
result: {
|
|
560
596
|
performed: true,
|
|
@@ -566,8 +602,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
566
602
|
};
|
|
567
603
|
}
|
|
568
604
|
catch {
|
|
569
|
-
if (session.versionSkew !== undefined)
|
|
605
|
+
if (session.versionSkew !== undefined) {
|
|
570
606
|
throw new Error(session.versionSkew);
|
|
607
|
+
}
|
|
571
608
|
return {
|
|
572
609
|
result: undefined,
|
|
573
610
|
settled: false,
|
|
@@ -585,8 +622,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
|
|
|
585
622
|
* A Proxy, not a copy: the session keeps private state its own methods read.
|
|
586
623
|
*/
|
|
587
624
|
export function withLeaseKeyboard(deps, session) {
|
|
588
|
-
if (deps.realInput !== undefined || deps.pool?.hasKeyboard(session.id) !== true)
|
|
625
|
+
if (deps.realInput !== undefined || deps.pool?.hasKeyboard(session.id) !== true) {
|
|
589
626
|
return session;
|
|
627
|
+
}
|
|
590
628
|
const command = async (name, args = {}, timeoutMs) => {
|
|
591
629
|
if (ReticleCommand.ACT === name && ActionType.PRESS === args['action']) {
|
|
592
630
|
const ref = asString(args['ref']) ?? '';
|
|
@@ -600,8 +638,9 @@ export function withLeaseKeyboard(deps, session) {
|
|
|
600
638
|
};
|
|
601
639
|
return new Proxy(session, {
|
|
602
640
|
get(target, key) {
|
|
603
|
-
if ('command' === key)
|
|
641
|
+
if ('command' === key) {
|
|
604
642
|
return command;
|
|
643
|
+
}
|
|
605
644
|
const value = Reflect.get(target, key, target);
|
|
606
645
|
return 'function' === typeof value
|
|
607
646
|
? value.bind(target)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** The call in a streak that gets the hint. Two calls can be a deliberate retry; three is a loop. */
|
|
2
|
+
export declare const REPEAT_HINT_AT = 3;
|
|
3
|
+
export declare const RepeatHint: {
|
|
4
|
+
readonly NO_MATCH: string;
|
|
5
|
+
/** A no_match whose refusal listed the nearest targets: the retry is one of those refs. */
|
|
6
|
+
readonly noMatchNearest: (ref: string) => string;
|
|
7
|
+
readonly LOOK_SAME: string;
|
|
8
|
+
readonly RECOVERY: string;
|
|
9
|
+
readonly SAME: `You made this call ${string} times in a row with the same result.`;
|
|
10
|
+
/** A verdict repeated: name its own reason, the only thing the result says about why. */
|
|
11
|
+
readonly verdict: (verified: string, because: string | undefined) => string;
|
|
12
|
+
};
|
|
13
|
+
/** What a call came back with, as far as a repeat is concerned: an identity and the line to say. */
|
|
14
|
+
export interface RepeatOutcome {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly hint: string;
|
|
17
|
+
}
|
|
18
|
+
/** The outcome of a call that threw: its refusal reason. */
|
|
19
|
+
export declare function outcomeOfError(error: unknown): RepeatOutcome;
|
|
20
|
+
/** The outcome of a call that answered: its refusal reason, its verdict, or its whole answer. */
|
|
21
|
+
export declare function outcomeOfResult(tool: string, result: unknown): RepeatOutcome;
|
|
22
|
+
/** Tests only. */
|
|
23
|
+
export declare function resetRepeatHints(): void;
|
|
24
|
+
/**
|
|
25
|
+
* Note one call of one connection, and answer the hint when this call is the third in a streak.
|
|
26
|
+
* `owner` is the agent connection; undefined (the Harness, the HUD, an embedding) is never counted.
|
|
27
|
+
*/
|
|
28
|
+
export declare function noteRepeat(owner: string | undefined, tool: string, args: Record<string, unknown>, outcome: RepeatOutcome): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Run one call of one connection inside its repeat streak. Answers the result and, on the third
|
|
31
|
+
* identical call, the hint. A call that throws keeps its hint beside the error (`withRepeatHint`).
|
|
32
|
+
*/
|
|
33
|
+
export declare function inRepeatStreak(owner: string | undefined, tool: string, args: Record<string, unknown>, run: () => Promise<unknown>): Promise<{
|
|
34
|
+
raw: unknown;
|
|
35
|
+
hint: string | undefined;
|
|
36
|
+
}>;
|
|
37
|
+
/** The answer with the hint beside it. Extra text only: the verdict and every field stay as they are. */
|
|
38
|
+
export declare function withHintOn(answer: unknown, hint: string | undefined): unknown;
|
|
39
|
+
/** The error payload with the hint its call earned, if any. */
|
|
40
|
+
export declare function withRepeatHint<T extends object>(payload: T, error: unknown): T;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One hint when an agent makes the same call, with the same result, for the third time in a row.
|
|
3
|
+
*
|
|
4
|
+
* Agents in the field made the same call five times or more in a row. Each answer was the same, and
|
|
5
|
+
* nothing in it said so. This module remembers the LAST call of each agent connection and gives one
|
|
6
|
+
* line, on the third identical call, that says what to try instead.
|
|
7
|
+
*
|
|
8
|
+
* Advice only: it reads a result that is already decided and never changes or blocks it. A hint says
|
|
9
|
+
* only what the result supports. When there is nothing specific to say, it says that the call was
|
|
10
|
+
* repeated with the same result.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { RefusalReason, ReticleTool, isPlainRecord } from '@reticlehq/core';
|
|
14
|
+
import { resultIsError } from '../mcp/faults/mcp-is-error.js';
|
|
15
|
+
import { buildErrorPayload, codedRefusalOf, refusalReasonFor } from './error-recovery.js';
|
|
16
|
+
import { EnvelopeKey } from './tool-kit.js';
|
|
17
|
+
import { NEAREST_TARGETS_LEAD } from './act/resolve-target.js';
|
|
18
|
+
/** The call in a streak that gets the hint. Two calls can be a deliberate retry; three is a loop. */
|
|
19
|
+
export const REPEAT_HINT_AT = 3;
|
|
20
|
+
const COUNT = String(REPEAT_HINT_AT);
|
|
21
|
+
export const RepeatHint = {
|
|
22
|
+
NO_MATCH: `You made this call ${COUNT} times and each time no element matched. Do not send it again. ` +
|
|
23
|
+
'Take one reticle_look { action: "find" } and use a ref from its answer.',
|
|
24
|
+
/** A no_match whose refusal listed the nearest targets: the retry is one of those refs. */
|
|
25
|
+
noMatchNearest: (ref) => `You made this call ${COUNT} times and each time no element matched. Do not send it again. ` +
|
|
26
|
+
`The refusal lists the nearest targets. Pass one of their refs as \`ref\`, for example "${ref}".`,
|
|
27
|
+
LOOK_SAME: `You made this look ${COUNT} times and each answer was the same, so the page did not change. ` +
|
|
28
|
+
'Act on the page, or wait for a change with reticle_assert { action: "wait" }.',
|
|
29
|
+
RECOVERY: `You made this call ${COUNT} times and it was refused for the same reason each time. ` +
|
|
30
|
+
'Do the step in `recovery` before you send it again.',
|
|
31
|
+
SAME: `You made this call ${COUNT} times in a row with the same result.`,
|
|
32
|
+
/** A verdict repeated: name its own reason, the only thing the result says about why. */
|
|
33
|
+
verdict: (verified, because) => `You made this call ${COUNT} times and each verdict was "${verified}".` +
|
|
34
|
+
(because === undefined ? '' : ` Its reason: "${because}".`) +
|
|
35
|
+
' The same call gives the same verdict. Change the target, the action or the `until`.',
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Result keys that change from call to call when nothing else did: the dispatcher's own advisories,
|
|
39
|
+
* the screenshot path, and the clock. A key that is missed here only means no hint fires.
|
|
40
|
+
*/
|
|
41
|
+
const VOLATILE_KEYS = new Set([
|
|
42
|
+
...Object.values(EnvelopeKey),
|
|
43
|
+
'cursor',
|
|
44
|
+
'since',
|
|
45
|
+
'at',
|
|
46
|
+
'ms',
|
|
47
|
+
'elapsedMs',
|
|
48
|
+
'durationMs',
|
|
49
|
+
]);
|
|
50
|
+
/** The same JSON for the same value, whatever the key order. */
|
|
51
|
+
function stableJson(value) {
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
return `[${value.map(stableJson).join(',')}]`;
|
|
54
|
+
}
|
|
55
|
+
if (isPlainRecord(value)) {
|
|
56
|
+
const keys = Object.keys(value)
|
|
57
|
+
.filter((key) => value[key] !== undefined)
|
|
58
|
+
.sort();
|
|
59
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
60
|
+
}
|
|
61
|
+
return JSON.stringify(value) ?? 'undefined';
|
|
62
|
+
}
|
|
63
|
+
/** A short digest, so a connection keeps a few bytes and not a whole page tree. */
|
|
64
|
+
function digest(text) {
|
|
65
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
66
|
+
}
|
|
67
|
+
/** The first ref in a no_match refusal's nearest-target list, or undefined when it has no list. */
|
|
68
|
+
function firstNearestRef(message) {
|
|
69
|
+
const at = message.indexOf(NEAREST_TARGETS_LEAD);
|
|
70
|
+
if (-1 === at) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return /^(\S+) \(/.exec(message.slice(at + NEAREST_TARGETS_LEAD.length))?.[1];
|
|
74
|
+
}
|
|
75
|
+
function refusal(reason, recovery, message) {
|
|
76
|
+
const id = `refused:${reason}`;
|
|
77
|
+
if (RefusalReason.NO_MATCH === reason) {
|
|
78
|
+
const ref = firstNearestRef(message);
|
|
79
|
+
return { id, hint: ref === undefined ? RepeatHint.NO_MATCH : RepeatHint.noMatchNearest(ref) };
|
|
80
|
+
}
|
|
81
|
+
return { id, hint: 'string' === typeof recovery ? RepeatHint.RECOVERY : RepeatHint.SAME };
|
|
82
|
+
}
|
|
83
|
+
/** The outcome of a call that threw: its refusal reason. */
|
|
84
|
+
export function outcomeOfError(error) {
|
|
85
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
86
|
+
const coded = codedRefusalOf(error);
|
|
87
|
+
return refusal(refusalReasonFor(message, coded), buildErrorPayload(message, coded).recovery, message);
|
|
88
|
+
}
|
|
89
|
+
/** The outcome of a call that answered: its refusal reason, its verdict, or its whole answer. */
|
|
90
|
+
export function outcomeOfResult(tool, result) {
|
|
91
|
+
if (!isPlainRecord(result)) {
|
|
92
|
+
return { id: `value:${digest(stableJson(result))}`, hint: RepeatHint.SAME };
|
|
93
|
+
}
|
|
94
|
+
if (resultIsError(result)) {
|
|
95
|
+
const message = String(result['error']);
|
|
96
|
+
return refusal(refusalReasonFor(message), result['recovery'], message);
|
|
97
|
+
}
|
|
98
|
+
if ('verified' in result) {
|
|
99
|
+
const verified = String(result['verified']);
|
|
100
|
+
const because = 'string' === typeof result['because'] ? result['because'] : undefined;
|
|
101
|
+
return {
|
|
102
|
+
id: `verdict:${verified}:${because ?? ''}`,
|
|
103
|
+
hint: RepeatHint.verdict(verified, because),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const kept = Object.fromEntries(Object.entries(result).filter(([key]) => !VOLATILE_KEYS.has(key)));
|
|
107
|
+
return {
|
|
108
|
+
id: `answer:${digest(stableJson(kept))}`,
|
|
109
|
+
hint: ReticleTool.LOOK === tool ? RepeatHint.LOOK_SAME : RepeatHint.SAME,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Bound on remembered connections: a daemon outlives them. Each keeps only its last call. */
|
|
113
|
+
const MAX_CONNECTIONS = 500;
|
|
114
|
+
const lastByConnection = new Map();
|
|
115
|
+
/** Tests only. */
|
|
116
|
+
export function resetRepeatHints() {
|
|
117
|
+
lastByConnection.clear();
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Note one call of one connection, and answer the hint when this call is the third in a streak.
|
|
121
|
+
* `owner` is the agent connection; undefined (the Harness, the HUD, an embedding) is never counted.
|
|
122
|
+
*/
|
|
123
|
+
export function noteRepeat(owner, tool, args, outcome) {
|
|
124
|
+
if (owner === undefined) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const call = digest(`${tool}:${stableJson(args)}`);
|
|
128
|
+
const last = lastByConnection.get(owner);
|
|
129
|
+
const count = last?.call === call && last.outcome === outcome.id ? last.count + 1 : 1;
|
|
130
|
+
// Delete first, so the map order is last use and the oldest connection is the one that goes.
|
|
131
|
+
lastByConnection.delete(owner);
|
|
132
|
+
if (lastByConnection.size >= MAX_CONNECTIONS) {
|
|
133
|
+
const oldest = lastByConnection.keys().next().value;
|
|
134
|
+
if (oldest !== undefined) {
|
|
135
|
+
lastByConnection.delete(oldest);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
lastByConnection.set(owner, { call, outcome: outcome.id, count });
|
|
139
|
+
return REPEAT_HINT_AT === count ? outcome.hint : undefined;
|
|
140
|
+
}
|
|
141
|
+
/** Hints for calls that threw, kept beside the error so the boundary that answers can read them. */
|
|
142
|
+
const hintByError = new WeakMap();
|
|
143
|
+
/**
|
|
144
|
+
* Run one call of one connection inside its repeat streak. Answers the result and, on the third
|
|
145
|
+
* identical call, the hint. A call that throws keeps its hint beside the error (`withRepeatHint`).
|
|
146
|
+
*/
|
|
147
|
+
export async function inRepeatStreak(owner, tool, args, run) {
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = await run();
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
const hint = noteRepeat(owner, tool, args, outcomeOfError(error));
|
|
154
|
+
if (hint !== undefined && 'object' === typeof error && null !== error) {
|
|
155
|
+
hintByError.set(error, hint);
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
return { raw, hint: noteRepeat(owner, tool, args, outcomeOfResult(tool, raw)) };
|
|
160
|
+
}
|
|
161
|
+
/** The answer with the hint beside it. Extra text only: the verdict and every field stay as they are. */
|
|
162
|
+
export function withHintOn(answer, hint) {
|
|
163
|
+
return hint !== undefined && isPlainRecord(answer)
|
|
164
|
+
? { ...answer, [EnvelopeKey.REPEAT_HINT]: hint }
|
|
165
|
+
: answer;
|
|
166
|
+
}
|
|
167
|
+
/** The error payload with the hint its call earned, if any. */
|
|
168
|
+
export function withRepeatHint(payload, error) {
|
|
169
|
+
const hint = 'object' === typeof error && null !== error ? hintByError.get(error) : undefined;
|
|
170
|
+
return hint === undefined ? payload : { ...payload, [EnvelopeKey.REPEAT_HINT]: hint };
|
|
171
|
+
}
|