@reticlehq/server 2.6.0 → 2.7.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/SKILL.md +201 -18
- package/dist/bridge/bridge.d.ts +6 -1
- package/dist/bridge/bridge.js +27 -4
- package/dist/cli/chromium-hint.d.ts +40 -0
- package/dist/cli/chromium-hint.js +75 -0
- package/dist/cli/cli-doctor.js +3 -11
- package/dist/cli/cli-launch.d.ts +1 -0
- package/dist/cli/cli-launch.js +4 -1
- package/dist/cli/cli-port.js +20 -1
- package/dist/cli/doctor-daemon-line.js +10 -1
- package/dist/cli.js +40 -9
- package/dist/daemon/daemon-resilience.js +2 -2
- package/dist/daemon/loopback-alias.d.ts +32 -0
- package/dist/daemon/loopback-alias.js +80 -0
- package/dist/events/predicate-eval.d.ts +7 -3
- package/dist/events/predicate-eval.js +159 -92
- package/dist/events/predicate-parse.d.ts +1 -1
- package/dist/events/predicate-parse.js +18 -4
- package/dist/events/ring-buffer.d.ts +8 -0
- package/dist/events/ring-buffer.js +34 -1
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +9 -1
- package/dist/honesty/verified.js +26 -3
- package/dist/http-server.js +11 -1
- package/dist/index.js +26 -2
- package/dist/init/agent-rules.js +6 -1
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +15 -0
- package/dist/init/non-js-project.d.ts +27 -0
- package/dist/init/non-js-project.js +60 -0
- package/dist/init/plan-framework.d.ts +9 -0
- package/dist/init/plan-framework.js +19 -1
- package/dist/init/plan.js +20 -3
- package/dist/init/run.js +38 -9
- package/dist/init/snippets.d.ts +20 -1
- package/dist/init/snippets.js +71 -6
- package/dist/mcp/mcp-proxy.js +10 -3
- package/dist/mcp/mcp.js +35 -0
- package/dist/mcp/proxy-lifecycle.d.ts +19 -0
- package/dist/mcp/proxy-lifecycle.js +21 -0
- package/dist/pool/playwright-launcher.js +10 -2
- package/dist/session/live-control-tools.js +39 -2
- package/dist/session/live-control.d.ts +7 -9
- package/dist/session/live-control.js +34 -1
- package/dist/session/no-session-diagnosis.d.ts +4 -4
- package/dist/session/no-session-diagnosis.js +60 -29
- package/dist/session/review-store.d.ts +19 -0
- package/dist/session/review-store.js +34 -5
- package/dist/session/session-manager.d.ts +9 -0
- package/dist/session/session-manager.js +11 -0
- package/dist/session/session.d.ts +8 -0
- package/dist/session/session.js +12 -0
- package/dist/status-payload.d.ts +13 -1
- package/dist/status-payload.js +6 -1
- package/dist/telemetry/app-instrumented.d.ts +37 -0
- package/dist/telemetry/app-instrumented.js +54 -0
- package/dist/telemetry/daemon-telemetry.d.ts +4 -4
- package/dist/telemetry/daemon-telemetry.js +8 -4
- package/dist/telemetry/session-metrics.d.ts +3 -3
- package/dist/telemetry/session-metrics.js +4 -4
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +3 -1
- package/dist/telemetry/verification-of.js +26 -1
- package/dist/tools/act-tools.js +32 -10
- package/dist/tools/assert-verdict.js +7 -0
- package/dist/tools/error-recovery.js +6 -1
- package/dist/tools/invoke-tool.js +11 -10
- package/dist/tools/tool-kit.d.ts +52 -5
- package/dist/tools/tool-kit.js +55 -24
- package/dist/tools/tool-surface.js +9 -9
- package/dist/tools/tools.js +22 -0
- package/dist/visual/capture-cleanup.d.ts +16 -0
- package/dist/visual/capture-cleanup.js +39 -0
- package/dist/visual/visual-tools.d.ts +18 -0
- package/dist/visual/visual-tools.js +24 -5
- package/docs/agent-cheatsheet.md +14 -2
- package/docs/gate-plan.md +1 -1
- package/docs/gates.md +1 -1
- package/docs/telemetry-contract.md +13 -1
- package/docs/telemetry.md +9 -1
- package/docs/token-efficiency.md +2 -2
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -132,6 +132,10 @@ async function serveWithHonestExit(parsed) {
|
|
|
132
132
|
/** How long `serve` waits for the child to bind. Generous: a cold daemon start is seconds. */
|
|
133
133
|
const SERVE_BIND_TIMEOUT_MS = 15_000;
|
|
134
134
|
const PRESENCE_POLL_MS = 150;
|
|
135
|
+
/** How long a daemon gets to shut down on its own before `stop` stops asking politely. */
|
|
136
|
+
const GRACEFUL_STOP_MS = 5000;
|
|
137
|
+
/** How long after SIGKILL before we accept the process is not ours to kill and say so. */
|
|
138
|
+
const FORCED_STOP_MS = 2000;
|
|
135
139
|
async function waitForPresence(port, want, timeoutMs) {
|
|
136
140
|
const deadline = Date.now() + timeoutMs;
|
|
137
141
|
for (;;) {
|
|
@@ -153,18 +157,35 @@ function handleStop(port, quiet) {
|
|
|
153
157
|
}
|
|
154
158
|
process.kill(pid, 'SIGTERM');
|
|
155
159
|
const started = Date.now();
|
|
160
|
+
let escalated = false;
|
|
156
161
|
const poll = setInterval(() => {
|
|
157
162
|
if (!isAlive(pid)) {
|
|
158
163
|
clearInterval(poll);
|
|
159
164
|
removePid(port);
|
|
160
165
|
if (!quiet)
|
|
161
|
-
log('reticle_daemon_stopped', { port, pid });
|
|
166
|
+
log('reticle_daemon_stopped', { port, pid, ...(escalated ? { escalated } : {}) });
|
|
162
167
|
return;
|
|
163
168
|
}
|
|
164
|
-
|
|
169
|
+
const waited = Date.now() - started;
|
|
170
|
+
// A daemon that ignores SIGTERM is WEDGED — which is the one situation `stop` exists for. Giving
|
|
171
|
+
// up here left the port held, the pid file stale, and a human running `kill -9` by hand at the
|
|
172
|
+
// first two minutes of setup. Escalate instead: graceful first, then unconditional.
|
|
173
|
+
if (!escalated && waited > GRACEFUL_STOP_MS) {
|
|
174
|
+
escalated = true;
|
|
175
|
+
try {
|
|
176
|
+
process.kill(pid, 'SIGKILL');
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Already gone between the liveness check and here — the next tick reports it stopped.
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (waited > GRACEFUL_STOP_MS + FORCED_STOP_MS) {
|
|
165
184
|
clearInterval(poll);
|
|
185
|
+
// Surviving SIGKILL means the pid is not ours to kill (permissions) or is an unkillable
|
|
186
|
+
// zombie. Neither is retryable, and both need the pid named so a human can act on it.
|
|
166
187
|
if (!quiet)
|
|
167
|
-
log('reticle_daemon_stop_timeout', { port, pid });
|
|
188
|
+
log('reticle_daemon_stop_timeout', { port, pid, escalated });
|
|
168
189
|
process.exit(1);
|
|
169
190
|
}
|
|
170
191
|
}, 100);
|
|
@@ -360,11 +381,21 @@ function handleOpen(requestedPort, url) {
|
|
|
360
381
|
...(connected
|
|
361
382
|
? {}
|
|
362
383
|
: {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
384
|
+
// Two claims used to live here that this command cannot support. It said the browser
|
|
385
|
+
// "was launched" — it asked the OS to open a URL and cannot see whether a window
|
|
386
|
+
// appeared — and it offered "the app may still be loading" as a cause with equal
|
|
387
|
+
// weight to the real one. It then pointed at `reticle doctor`, whose Chromium check is
|
|
388
|
+
// about a browser THIS command never uses, so a missing Chromium reads as the
|
|
389
|
+
// explanation for a session that is missing for an unrelated reason. Both misdirects
|
|
390
|
+
// were reported from the field, each costing several calls of app-side wiring hunt.
|
|
391
|
+
note: `the URL was handed to the system default browser (this command does not use ` +
|
|
392
|
+
`Reticle's own Chromium, so a chromium warning from \`reticle doctor\` is unrelated ` +
|
|
393
|
+
`to this). No Reticle session appeared. By far the likeliest cause is that the app ` +
|
|
394
|
+
`carries no Reticle SDK, or dials a port other than ${String(port)} — run ` +
|
|
395
|
+
`\`reticle init\` in the app's directory and restart its dev server. If the app IS ` +
|
|
396
|
+
`wired, give the page a moment and check the browser console: the SDK announces its ` +
|
|
397
|
+
`own connect failures there, including the one it refuses to make from a ` +
|
|
398
|
+
`non-localhost host without allowNonLocalhost.`,
|
|
368
399
|
}),
|
|
369
400
|
});
|
|
370
401
|
})
|
|
@@ -420,7 +451,7 @@ function handleDaemonInner(parsed) {
|
|
|
420
451
|
// The reason rides out on the session summary, which is the only event that fires at this
|
|
421
452
|
// exact moment. The proxy emits the matching `mcp_connection_lost` and cannot know it —
|
|
422
453
|
// it sees a socket end and nothing more, which is how a scheduled idle exit came to make up
|
|
423
|
-
//
|
|
454
|
+
// the large majority of "outages". See SessionSummary.exit.
|
|
424
455
|
.shutdown(reason)
|
|
425
456
|
.then(() => server.close())
|
|
426
457
|
.then(() => {
|
|
@@ -42,8 +42,8 @@ const DISCONNECT_CODES = new Set([
|
|
|
42
42
|
/**
|
|
43
43
|
* Nobody went away — nobody was ever there. A refused connect is the daemon not being up YET.
|
|
44
44
|
*
|
|
45
|
-
* This is the difference that made `runtime_crashed` useless.
|
|
46
|
-
*
|
|
45
|
+
* This is the difference that made `runtime_crashed` useless. In the field **every crash event
|
|
46
|
+
* carried one fingerprint, `connect ECONNREFUSED`, and every affected user was in the
|
|
47
47
|
* never-verified set.** The proxy is BUILT to tolerate this — it answers the handshake from cache
|
|
48
48
|
* and wakes a daemon on the next request — so reporting it as a crash meant the one metric that is
|
|
49
49
|
* supposed to say "Reticle broke" said nothing but "the daemon had not booted yet", and a real crash
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
|
|
3
|
+
*
|
|
4
|
+
* The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
|
|
5
|
+
* `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
|
|
6
|
+
* default bridge URL says `localhost`. So on the platform that is most of our installs, and the
|
|
7
|
+
* browser that is most of that, the documented default cannot connect — and the error text names the
|
|
8
|
+
* daemon, containers and WSL, none of which is the cause.
|
|
9
|
+
*
|
|
10
|
+
* This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
|
|
11
|
+
* WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
|
|
12
|
+
* second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
|
|
13
|
+
* only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
|
|
14
|
+
*
|
|
15
|
+
* Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
|
|
16
|
+
* leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
|
|
17
|
+
* precondition for serving.
|
|
18
|
+
*/
|
|
19
|
+
/** IPv6 loopback. The other half of what `localhost` can mean. */
|
|
20
|
+
export declare const IPV6_LOOPBACK = "::1";
|
|
21
|
+
export interface LoopbackAlias {
|
|
22
|
+
/** False when the alias could not be bound — the daemon still serves IPv4 as before. */
|
|
23
|
+
opened: boolean;
|
|
24
|
+
close: () => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
|
|
28
|
+
*
|
|
29
|
+
* Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
|
|
30
|
+
* trust holds — the alias does not widen who may connect, only which address they may name.
|
|
31
|
+
*/
|
|
32
|
+
export declare function openLoopbackAlias(port: number): Promise<LoopbackAlias>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve the IPv4-bound daemon on IPv6 loopback too, so `localhost` reaches it on every platform.
|
|
3
|
+
*
|
|
4
|
+
* The daemon binds `127.0.0.1` — deliberately, because Reticle must never be reachable off-host. But
|
|
5
|
+
* `localhost` is a NAME, and on Windows Chrome resolves it to `::1` before `127.0.0.1`. The SDK's
|
|
6
|
+
* default bridge URL says `localhost`. So on the platform that is most of our installs, and the
|
|
7
|
+
* browser that is most of that, the documented default cannot connect — and the error text names the
|
|
8
|
+
* daemon, containers and WSL, none of which is the cause.
|
|
9
|
+
*
|
|
10
|
+
* This is a byte forwarder rather than a second `http.Server` on purpose. The daemon speaks HTTP,
|
|
11
|
+
* WebSocket upgrades and SSE on one port; copying request/upgrade wiring onto a second server means a
|
|
12
|
+
* second place to keep in sync, and the first handler anybody forgets to mirror becomes a bug that
|
|
13
|
+
* only Windows sees. A TCP pipe carries all three and cannot drift from what it forwards.
|
|
14
|
+
*
|
|
15
|
+
* Best-effort by contract: a machine with IPv6 disabled, or something already holding `[::1]:port`,
|
|
16
|
+
* leaves the daemon exactly as it was before. Never fatal — this widens reachability, it is not a
|
|
17
|
+
* precondition for serving.
|
|
18
|
+
*/
|
|
19
|
+
import net from 'node:net';
|
|
20
|
+
import { LOOPBACK_HOST } from '@reticlehq/core';
|
|
21
|
+
/** IPv6 loopback. The other half of what `localhost` can mean. */
|
|
22
|
+
export const IPV6_LOOPBACK = '::1';
|
|
23
|
+
/**
|
|
24
|
+
* Accept on `[::1]:port` and pipe each connection to `127.0.0.1:port`.
|
|
25
|
+
*
|
|
26
|
+
* Both ends are loopback, so a forwarded peer is still a loopback peer and the bridge's loopback
|
|
27
|
+
* trust holds — the alias does not widen who may connect, only which address they may name.
|
|
28
|
+
*/
|
|
29
|
+
export function openLoopbackAlias(port) {
|
|
30
|
+
/** Every socket this alias has open, so shutdown can end them instead of waiting them out. */
|
|
31
|
+
const live = new Set();
|
|
32
|
+
const server = net.createServer((inbound) => {
|
|
33
|
+
const outbound = net.connect(port, LOOPBACK_HOST);
|
|
34
|
+
live.add(inbound).add(outbound);
|
|
35
|
+
const drop = (socket) => {
|
|
36
|
+
live.delete(socket);
|
|
37
|
+
};
|
|
38
|
+
inbound.on('close', () => drop(inbound));
|
|
39
|
+
outbound.on('close', () => drop(outbound));
|
|
40
|
+
// A dead peer on either side must tear down the pair, not surface as an unhandled 'error'
|
|
41
|
+
// event — which in Node terminates the daemon this alias exists to make reachable.
|
|
42
|
+
inbound.on('error', () => outbound.destroy());
|
|
43
|
+
outbound.on('error', () => inbound.destroy());
|
|
44
|
+
inbound.pipe(outbound);
|
|
45
|
+
outbound.pipe(inbound);
|
|
46
|
+
});
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
/**
|
|
49
|
+
* Stop listening and END EVERYTHING, rather than draining.
|
|
50
|
+
*
|
|
51
|
+
* `server.close()` fires its callback only once every existing connection has gone, so a single
|
|
52
|
+
* held-open socket — a keep-alive, an SSE stream, exactly what this daemon serves — means the
|
|
53
|
+
* callback never fires. The daemon's shutdown chain awaits this, so an un-drained alias silently
|
|
54
|
+
* became a daemon that ignores SIGTERM: `reticle stop` timed out, the process stayed alive, and
|
|
55
|
+
* the port stayed held. Caught by the heartbeat spec, which is the one gate that watches a
|
|
56
|
+
* daemon actually die.
|
|
57
|
+
*
|
|
58
|
+
* A best-effort side listener must never be able to hold up the shutdown of the thing it exists
|
|
59
|
+
* to make reachable. We are exiting; ending these sockets is the correct outcome, not a
|
|
60
|
+
* compromise.
|
|
61
|
+
*/
|
|
62
|
+
const close = () => new Promise((done) => {
|
|
63
|
+
server.close(() => done());
|
|
64
|
+
for (const socket of live)
|
|
65
|
+
socket.destroy();
|
|
66
|
+
live.clear();
|
|
67
|
+
});
|
|
68
|
+
server.once('error', () => {
|
|
69
|
+
// Nothing was bound, so there is nothing to close — but the caller still gets a close it can
|
|
70
|
+
// call unconditionally, rather than a shape it has to branch on.
|
|
71
|
+
resolve({ opened: false, close: () => Promise.resolve() });
|
|
72
|
+
});
|
|
73
|
+
server.listen(port, IPV6_LOOPBACK, () => {
|
|
74
|
+
// Never the reason the process stays up: if the daemon is otherwise done, this must not be
|
|
75
|
+
// what keeps the event loop alive.
|
|
76
|
+
server.unref();
|
|
77
|
+
resolve({ opened: true, close });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -58,11 +58,15 @@ export type Predicate = {
|
|
|
58
58
|
kind: typeof PredicateKind.NOT;
|
|
59
59
|
predicate: Predicate;
|
|
60
60
|
};
|
|
61
|
+
export declare const PredicateSchema: z.ZodType<Predicate>;
|
|
61
62
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
63
|
+
* The fields a given predicate kind accepts, read off the schema itself.
|
|
64
|
+
*
|
|
65
|
+
* Derived rather than listed so the two can never disagree: a rejection message that names a stale
|
|
66
|
+
* field set is worse than one that names none, because the agent trusts it and retries into the same
|
|
67
|
+
* wall. Empty for a kind that is not in the union.
|
|
64
68
|
*/
|
|
65
|
-
export declare
|
|
69
|
+
export declare function predicateFieldsFor(kind: string): readonly string[];
|
|
66
70
|
export interface EvalResult {
|
|
67
71
|
pass: boolean;
|
|
68
72
|
/**
|
|
@@ -9,6 +9,11 @@ import { describeObserved } from './observed-in-window.js';
|
|
|
9
9
|
* the only key supplied left a predicate that asserts nothing and passes on anything.
|
|
10
10
|
*/
|
|
11
11
|
const PREDICATE_ALIASES = {
|
|
12
|
+
// `text`/`value` on a `text` predicate: the kind is called "text", so `text:` is the first thing
|
|
13
|
+
// anyone writes for it, and `value` follows from the element query's own `value` field. Both were
|
|
14
|
+
// hard rejections, and a rejected predicate produces NO verdict at all — the drive ends with
|
|
15
|
+
// nothing rather than with a failure, which is the worst outcome of the three.
|
|
16
|
+
[PredicateKind.TEXT]: { text: 'contains', value: 'contains' },
|
|
12
17
|
// `urlContains`/`url` reported from the field: an agent that had just written
|
|
13
18
|
// `net { urlContains }` applied the same word to `route`, which spells it `contains`, and got
|
|
14
19
|
// `unrecognized_keys` with no list of what would have worked. The parallel it assumed is a fair
|
|
@@ -19,111 +24,173 @@ const PREDICATE_ALIASES = {
|
|
|
19
24
|
[PredicateKind.NET]: { url: 'urlContains' },
|
|
20
25
|
[PredicateKind.SIGNAL]: { data: 'dataMatches' },
|
|
21
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Element-query fields an agent writes FLAT on an `element` predicate instead of nested under
|
|
29
|
+
* `query`. `reticle_query` takes exactly these at the top level, so an agent that has just located
|
|
30
|
+
* something writes the same words again when it asserts on it — and got `query: Required` plus an
|
|
31
|
+
* `unknown field` list for its trouble. Lifting them is unambiguous: they have no other meaning on
|
|
32
|
+
* this kind.
|
|
33
|
+
*/
|
|
34
|
+
const ELEMENT_QUERY_FIELDS = [
|
|
35
|
+
'by',
|
|
36
|
+
'value',
|
|
37
|
+
'role',
|
|
38
|
+
'name',
|
|
39
|
+
'text',
|
|
40
|
+
'label',
|
|
41
|
+
'placeholder',
|
|
42
|
+
'testid',
|
|
43
|
+
'alt',
|
|
44
|
+
'component',
|
|
45
|
+
];
|
|
22
46
|
/** Rename known aliases before parse; an explicit canonical key always wins. */
|
|
23
47
|
function applyPredicateAliases(input) {
|
|
24
48
|
if (typeof input !== 'object' || null === input || Array.isArray(input))
|
|
25
49
|
return input;
|
|
26
50
|
const obj = input;
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
const kind = 'string' === typeof obj['kind'] ? obj['kind'] : '';
|
|
52
|
+
const aliases = PREDICATE_ALIASES[kind];
|
|
53
|
+
let out = obj;
|
|
54
|
+
if (aliases !== undefined) {
|
|
55
|
+
out = { ...obj };
|
|
56
|
+
for (const [from, to] of Object.entries(aliases)) {
|
|
57
|
+
if (out[from] === undefined)
|
|
58
|
+
continue;
|
|
59
|
+
if (out[to] === undefined)
|
|
60
|
+
out[to] = out[from];
|
|
61
|
+
delete out[from];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return PredicateKind.ELEMENT === kind ? liftElementQuery(out) : out;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Fold flat query fields into `query`. An explicit `query` wins outright — a caller that supplied
|
|
68
|
+
* both told us which one it meant, and merging the two would invent a locator neither side wrote.
|
|
69
|
+
*/
|
|
70
|
+
function liftElementQuery(obj) {
|
|
71
|
+
const loose = ELEMENT_QUERY_FIELDS.filter((field) => obj[field] !== undefined);
|
|
72
|
+
if (0 === loose.length)
|
|
73
|
+
return obj;
|
|
30
74
|
const out = { ...obj };
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
out[to] = out[from];
|
|
36
|
-
delete out[from];
|
|
75
|
+
const query = {};
|
|
76
|
+
for (const field of loose) {
|
|
77
|
+
query[field] = out[field];
|
|
78
|
+
delete out[field];
|
|
37
79
|
}
|
|
80
|
+
if (out['query'] === undefined)
|
|
81
|
+
out['query'] = query;
|
|
38
82
|
return out;
|
|
39
83
|
}
|
|
40
84
|
/**
|
|
41
85
|
* Strict on every branch. A key that is nobody's spelling is now a schema error naming it, instead of
|
|
42
86
|
* a stripped field and a green — see predicate-strict.test.ts for the MCP session that found this.
|
|
87
|
+
*
|
|
88
|
+
* Built on demand rather than at module scope: the `allOf`/`anyOf`/`not` branches reference
|
|
89
|
+
* `PredicateSchema` itself, which does not exist yet while this module is initialising. Calling it
|
|
90
|
+
* after init is what makes the union introspectable — see `predicateFieldsFor`.
|
|
91
|
+
*/
|
|
92
|
+
function predicateUnion() {
|
|
93
|
+
return z.discriminatedUnion('kind', [
|
|
94
|
+
z
|
|
95
|
+
.object({
|
|
96
|
+
kind: z.literal(PredicateKind.ELEMENT),
|
|
97
|
+
query: ElementQuerySchema,
|
|
98
|
+
state: z.nativeEnum(ElementState).optional(),
|
|
99
|
+
absent: z.boolean().optional(),
|
|
100
|
+
})
|
|
101
|
+
.strict(),
|
|
102
|
+
z
|
|
103
|
+
.object({
|
|
104
|
+
kind: z.literal(PredicateKind.TEXT),
|
|
105
|
+
contains: z.string(),
|
|
106
|
+
visible: z.boolean().optional(),
|
|
107
|
+
absent: z.boolean().optional(),
|
|
108
|
+
})
|
|
109
|
+
.strict(),
|
|
110
|
+
z
|
|
111
|
+
.object({
|
|
112
|
+
kind: z.literal(PredicateKind.NET),
|
|
113
|
+
method: z.string().optional(),
|
|
114
|
+
urlContains: z.string().optional(),
|
|
115
|
+
status: z.number().optional(),
|
|
116
|
+
ok: z.boolean().optional(),
|
|
117
|
+
since: z.number().optional(),
|
|
118
|
+
count: z.number().int().nonnegative().optional(),
|
|
119
|
+
})
|
|
120
|
+
.strict(),
|
|
121
|
+
z
|
|
122
|
+
.object({
|
|
123
|
+
kind: z.literal(PredicateKind.ROUTE),
|
|
124
|
+
pathname: z.string().optional(),
|
|
125
|
+
contains: z.string().optional(),
|
|
126
|
+
since: z.number().optional(),
|
|
127
|
+
})
|
|
128
|
+
.strict(),
|
|
129
|
+
z
|
|
130
|
+
.object({
|
|
131
|
+
kind: z.literal(PredicateKind.CONSOLE),
|
|
132
|
+
level: z.string().optional(),
|
|
133
|
+
absent: z.boolean().optional(),
|
|
134
|
+
since: z.number().optional(),
|
|
135
|
+
})
|
|
136
|
+
.strict(),
|
|
137
|
+
z
|
|
138
|
+
.object({
|
|
139
|
+
kind: z.literal(PredicateKind.ANIMATION),
|
|
140
|
+
name: z.string().optional(),
|
|
141
|
+
target: z.string().optional(),
|
|
142
|
+
completed: z.boolean().optional(),
|
|
143
|
+
since: z.number().optional(),
|
|
144
|
+
})
|
|
145
|
+
.strict(),
|
|
146
|
+
z
|
|
147
|
+
.object({
|
|
148
|
+
kind: z.literal(PredicateKind.SIGNAL),
|
|
149
|
+
name: z.string().optional(),
|
|
150
|
+
dataMatches: z.record(z.unknown()).optional(),
|
|
151
|
+
since: z.number().optional(),
|
|
152
|
+
})
|
|
153
|
+
.strict(),
|
|
154
|
+
z
|
|
155
|
+
.object({
|
|
156
|
+
kind: z.literal(PredicateKind.STATE),
|
|
157
|
+
store: z.string().optional(),
|
|
158
|
+
path: z.string(),
|
|
159
|
+
equals: z.unknown().optional(),
|
|
160
|
+
})
|
|
161
|
+
.strict(),
|
|
162
|
+
z
|
|
163
|
+
.object({
|
|
164
|
+
kind: z.literal(PredicateKind.SETTLED),
|
|
165
|
+
quietMs: z.number().positive().optional(),
|
|
166
|
+
})
|
|
167
|
+
.strict(),
|
|
168
|
+
z
|
|
169
|
+
.object({ kind: z.literal(PredicateKind.ALL_OF), predicates: z.array(PredicateSchema) })
|
|
170
|
+
.strict(),
|
|
171
|
+
z
|
|
172
|
+
.object({ kind: z.literal(PredicateKind.ANY_OF), predicates: z.array(PredicateSchema) })
|
|
173
|
+
.strict(),
|
|
174
|
+
z.object({ kind: z.literal(PredicateKind.NOT), predicate: PredicateSchema }).strict(),
|
|
175
|
+
]);
|
|
176
|
+
}
|
|
177
|
+
export const PredicateSchema = z.lazy(() => z.preprocess(applyPredicateAliases, predicateUnion()));
|
|
178
|
+
/**
|
|
179
|
+
* The fields a given predicate kind accepts, read off the schema itself.
|
|
180
|
+
*
|
|
181
|
+
* Derived rather than listed so the two can never disagree: a rejection message that names a stale
|
|
182
|
+
* field set is worse than one that names none, because the agent trusts it and retries into the same
|
|
183
|
+
* wall. Empty for a kind that is not in the union.
|
|
43
184
|
*/
|
|
44
|
-
export
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
z
|
|
54
|
-
.object({
|
|
55
|
-
kind: z.literal(PredicateKind.TEXT),
|
|
56
|
-
contains: z.string(),
|
|
57
|
-
visible: z.boolean().optional(),
|
|
58
|
-
absent: z.boolean().optional(),
|
|
59
|
-
})
|
|
60
|
-
.strict(),
|
|
61
|
-
z
|
|
62
|
-
.object({
|
|
63
|
-
kind: z.literal(PredicateKind.NET),
|
|
64
|
-
method: z.string().optional(),
|
|
65
|
-
urlContains: z.string().optional(),
|
|
66
|
-
status: z.number().optional(),
|
|
67
|
-
ok: z.boolean().optional(),
|
|
68
|
-
since: z.number().optional(),
|
|
69
|
-
count: z.number().int().nonnegative().optional(),
|
|
70
|
-
})
|
|
71
|
-
.strict(),
|
|
72
|
-
z
|
|
73
|
-
.object({
|
|
74
|
-
kind: z.literal(PredicateKind.ROUTE),
|
|
75
|
-
pathname: z.string().optional(),
|
|
76
|
-
contains: z.string().optional(),
|
|
77
|
-
since: z.number().optional(),
|
|
78
|
-
})
|
|
79
|
-
.strict(),
|
|
80
|
-
z
|
|
81
|
-
.object({
|
|
82
|
-
kind: z.literal(PredicateKind.CONSOLE),
|
|
83
|
-
level: z.string().optional(),
|
|
84
|
-
absent: z.boolean().optional(),
|
|
85
|
-
since: z.number().optional(),
|
|
86
|
-
})
|
|
87
|
-
.strict(),
|
|
88
|
-
z
|
|
89
|
-
.object({
|
|
90
|
-
kind: z.literal(PredicateKind.ANIMATION),
|
|
91
|
-
name: z.string().optional(),
|
|
92
|
-
target: z.string().optional(),
|
|
93
|
-
completed: z.boolean().optional(),
|
|
94
|
-
since: z.number().optional(),
|
|
95
|
-
})
|
|
96
|
-
.strict(),
|
|
97
|
-
z
|
|
98
|
-
.object({
|
|
99
|
-
kind: z.literal(PredicateKind.SIGNAL),
|
|
100
|
-
name: z.string().optional(),
|
|
101
|
-
dataMatches: z.record(z.unknown()).optional(),
|
|
102
|
-
since: z.number().optional(),
|
|
103
|
-
})
|
|
104
|
-
.strict(),
|
|
105
|
-
z
|
|
106
|
-
.object({
|
|
107
|
-
kind: z.literal(PredicateKind.STATE),
|
|
108
|
-
store: z.string().optional(),
|
|
109
|
-
path: z.string(),
|
|
110
|
-
equals: z.unknown().optional(),
|
|
111
|
-
})
|
|
112
|
-
.strict(),
|
|
113
|
-
z
|
|
114
|
-
.object({
|
|
115
|
-
kind: z.literal(PredicateKind.SETTLED),
|
|
116
|
-
quietMs: z.number().positive().optional(),
|
|
117
|
-
})
|
|
118
|
-
.strict(),
|
|
119
|
-
z
|
|
120
|
-
.object({ kind: z.literal(PredicateKind.ALL_OF), predicates: z.array(PredicateSchema) })
|
|
121
|
-
.strict(),
|
|
122
|
-
z
|
|
123
|
-
.object({ kind: z.literal(PredicateKind.ANY_OF), predicates: z.array(PredicateSchema) })
|
|
124
|
-
.strict(),
|
|
125
|
-
z.object({ kind: z.literal(PredicateKind.NOT), predicate: PredicateSchema }).strict(),
|
|
126
|
-
])));
|
|
185
|
+
export function predicateFieldsFor(kind) {
|
|
186
|
+
for (const option of predicateUnion().options) {
|
|
187
|
+
const literal = option.shape['kind'];
|
|
188
|
+
if (literal instanceof z.ZodLiteral && literal.value === kind) {
|
|
189
|
+
return Object.keys(option.shape).filter((field) => 'kind' !== field);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
127
194
|
function str(value) {
|
|
128
195
|
return 'string' === typeof value ? value : undefined;
|
|
129
196
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One readable sentence when a predicate does not parse — never the zod array.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* In the field a meaningful share of all tool errors were **a serialized zod issue array**, all on
|
|
5
5
|
* `reticle_act_and_wait`, `reticle_wait_for` and `reticle_assert` — the three tools that produced
|
|
6
6
|
* every action-derived finding in the dataset. The least readable error we emit was landing on the
|
|
7
7
|
* highest-value path, and an agent had to `JSON.parse` an error string to learn which field it got
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One readable sentence when a predicate does not parse — never the zod array.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* In the field a meaningful share of all tool errors were **a serialized zod issue array**, all on
|
|
5
5
|
* `reticle_act_and_wait`, `reticle_wait_for` and `reticle_assert` — the three tools that produced
|
|
6
6
|
* every action-derived finding in the dataset. The least readable error we emit was landing on the
|
|
7
7
|
* highest-value path, and an agent had to `JSON.parse` an error string to learn which field it got
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* show a valid call.
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod';
|
|
16
|
-
import {
|
|
16
|
+
import { PredicateKind } from '@reticlehq/core';
|
|
17
|
+
import { PredicateSchema, predicateFieldsFor } from './predicate-eval.js';
|
|
17
18
|
/** A valid call, short enough to sit inside an error without becoming the error. */
|
|
18
19
|
const EXAMPLE = '{ kind: "signal", name: "todos:loaded" }';
|
|
19
20
|
/** `path: ["net","urlContains"]` → `net.urlContains`; an empty path means the object itself. */
|
|
@@ -49,6 +50,19 @@ export function parsePredicate(input) {
|
|
|
49
50
|
// back is how the zod array became unreadable in the first place.
|
|
50
51
|
const issues = parsed.error.issues.slice(0, 3).map(describeIssue).join('; ');
|
|
51
52
|
throw new Error(`that predicate did not parse (kind "${kind}"): ${issues}. Nothing ran — the predicate was ` +
|
|
52
|
-
`not evaluated, so no verdict was produced.
|
|
53
|
-
`
|
|
53
|
+
`not evaluated, so no verdict was produced. ${accepted(kind)} ` +
|
|
54
|
+
`A valid call looks like: ${EXAMPLE}`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* What WOULD have worked, in the same breath as what did not.
|
|
58
|
+
*
|
|
59
|
+
* Telling an agent only which field is wrong leaves it guessing again, and each guess costs another
|
|
60
|
+
* round trip on the one call path that produces verdicts. Naming the accepted fields — or, when the
|
|
61
|
+
* kind itself is the mistake, the accepted kinds — makes the retry informed instead.
|
|
62
|
+
*/
|
|
63
|
+
function accepted(kind) {
|
|
64
|
+
const fields = predicateFieldsFor(kind);
|
|
65
|
+
if (0 < fields.length)
|
|
66
|
+
return `${kind} accepts: ${fields.join(', ')}.`;
|
|
67
|
+
return `"${kind}" is not a predicate kind — use one of: ${Object.values(PredicateKind).join(', ')}.`;
|
|
54
68
|
}
|
|
@@ -24,6 +24,14 @@ export declare class RingBuffer {
|
|
|
24
24
|
since(cursor: number): ReticleEvent[];
|
|
25
25
|
/** Events within the last `windowMs`, relative to `now`. */
|
|
26
26
|
window(windowMs: number, now: number): ReticleEvent[];
|
|
27
|
+
/**
|
|
28
|
+
* Did this buffer evict scarce evidence that belonged to a window opened at `cursor`?
|
|
29
|
+
*
|
|
30
|
+
* The one honest input to "was the capture clean". A `false` here means every non-churn event in
|
|
31
|
+
* the window is still held, whatever the drop counter says — and the drop counter says a great
|
|
32
|
+
* deal, because age eviction runs on every push.
|
|
33
|
+
*/
|
|
34
|
+
lostSince(cursor: number): boolean;
|
|
27
35
|
/** Snapshot of buffer health for the agent — live events held and cumulative drops since connect. */
|
|
28
36
|
bufferHealth(): {
|
|
29
37
|
total: number;
|
|
@@ -23,6 +23,16 @@ export class RingBuffer {
|
|
|
23
23
|
#head = 0;
|
|
24
24
|
#totalBytes = 0;
|
|
25
25
|
#droppedCount = 0;
|
|
26
|
+
/**
|
|
27
|
+
* The timestamp of the newest NON-CHURN event this buffer has ever evicted, or `undefined` if it
|
|
28
|
+
* has never lost one. This — not the drop counter — is what impeaches an observation window.
|
|
29
|
+
*
|
|
30
|
+
* The counter cannot answer the question. It moves for age eviction (everything past 60s, on every
|
|
31
|
+
* push) and for churn eviction (the low-signal floor, sacrificed on purpose so scarce evidence
|
|
32
|
+
* survives), neither of which is evidence lost from the window an agent just observed. See
|
|
33
|
+
* `lostSince` and `ring-buffer-window-loss.test.ts`.
|
|
34
|
+
*/
|
|
35
|
+
#lastScarceLossT;
|
|
26
36
|
constructor(options = {}) {
|
|
27
37
|
this.#maxEvents = options.maxEvents ?? RING_BUFFER_DEFAULTS.MAX_EVENTS;
|
|
28
38
|
this.#maxAgeMs = options.maxAgeMs ?? RING_BUFFER_DEFAULTS.MAX_AGE_MS;
|
|
@@ -81,7 +91,10 @@ export class RingBuffer {
|
|
|
81
91
|
}
|
|
82
92
|
const victim = this.#findChurnAfterHead();
|
|
83
93
|
if (-1 === victim) {
|
|
84
|
-
// Buffer genuinely full of high-signal events — fall back to FIFO so it stays bounded.
|
|
94
|
+
// Buffer genuinely full of high-signal events — fall back to FIFO so it stays bounded. This
|
|
95
|
+
// is the one eviction path that destroys scarce evidence, and the only one that should ever
|
|
96
|
+
// make a verdict say it could not see.
|
|
97
|
+
this.#noteScarceLoss(this.#head);
|
|
85
98
|
this.#totalBytes -= this.#eventBytes[this.#head] ?? 0;
|
|
86
99
|
this.#head += 1;
|
|
87
100
|
continue;
|
|
@@ -91,6 +104,7 @@ export class RingBuffer {
|
|
|
91
104
|
this.#eventBytes.splice(victim, 1);
|
|
92
105
|
}
|
|
93
106
|
while (this.#liveCount() > 0 && (this.#events[this.#head]?.t ?? cutoff) < cutoff) {
|
|
107
|
+
this.#noteScarceLoss(this.#head);
|
|
94
108
|
this.#totalBytes -= this.#eventBytes[this.#head] ?? 0;
|
|
95
109
|
this.#head += 1;
|
|
96
110
|
}
|
|
@@ -102,6 +116,25 @@ export class RingBuffer {
|
|
|
102
116
|
this.#head = 0;
|
|
103
117
|
}
|
|
104
118
|
}
|
|
119
|
+
/** Record an eviction at `index` as scarce loss, unless it was the churn floor being sacrificed. */
|
|
120
|
+
#noteScarceLoss(index) {
|
|
121
|
+
const victim = this.#events[index];
|
|
122
|
+
if (victim === undefined || CHURN_TYPES.has(victim.type))
|
|
123
|
+
return;
|
|
124
|
+
if (this.#lastScarceLossT === undefined || victim.t > this.#lastScarceLossT) {
|
|
125
|
+
this.#lastScarceLossT = victim.t;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Did this buffer evict scarce evidence that belonged to a window opened at `cursor`?
|
|
130
|
+
*
|
|
131
|
+
* The one honest input to "was the capture clean". A `false` here means every non-churn event in
|
|
132
|
+
* the window is still held, whatever the drop counter says — and the drop counter says a great
|
|
133
|
+
* deal, because age eviction runs on every push.
|
|
134
|
+
*/
|
|
135
|
+
lostSince(cursor) {
|
|
136
|
+
return this.#lastScarceLossT !== undefined && this.#lastScarceLossT >= cursor;
|
|
137
|
+
}
|
|
105
138
|
/** First churn event after the head, or -1 if none within the bounded scan (keeps eviction cheap). */
|
|
106
139
|
#findChurnAfterHead() {
|
|
107
140
|
const end = Math.min(this.#events.length, this.#head + CHURN_SCAN_LIMIT);
|