@reticlehq/server 2.5.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 +226 -26
- package/dist/bridge/bridge.d.ts +18 -1
- package/dist/bridge/bridge.js +59 -6
- package/dist/bridge/bridge.test-harness.d.ts +8 -0
- package/dist/bridge/bridge.test-harness.js +9 -1
- package/dist/cli/chromium-hint.d.ts +40 -0
- package/dist/cli/chromium-hint.js +75 -0
- package/dist/cli/cli-doctor.d.ts +0 -5
- package/dist/cli/cli-doctor.js +74 -14
- package/dist/cli/cli-launch.d.ts +1 -0
- package/dist/cli/cli-launch.js +4 -1
- package/dist/cli/cli-parse-feedback.d.ts +31 -0
- package/dist/cli/cli-parse-feedback.js +56 -0
- package/dist/cli/cli-parse.d.ts +5 -9
- package/dist/cli/cli-parse.js +7 -23
- package/dist/cli/cli-port.js +20 -1
- package/dist/cli/doctor-daemon-line.d.ts +37 -0
- package/dist/cli/doctor-daemon-line.js +44 -0
- package/dist/cli/port-holder.d.ts +43 -0
- package/dist/cli/port-holder.js +99 -0
- package/dist/cli.js +170 -19
- package/dist/daemon/daemon-resilience.d.ts +24 -0
- package/dist/daemon/daemon-resilience.js +105 -17
- package/dist/daemon/daemon.js +26 -1
- package/dist/daemon/heartbeat.d.ts +115 -0
- package/dist/daemon/heartbeat.js +141 -0
- package/dist/daemon/idle-shutdown.d.ts +13 -5
- package/dist/daemon/idle-shutdown.js +14 -6
- package/dist/daemon/loopback-alias.d.ts +32 -0
- package/dist/daemon/loopback-alias.js +80 -0
- package/dist/daemon/port-presence.d.ts +76 -0
- package/dist/daemon/port-presence.js +93 -0
- package/dist/daemon/state-dir.d.ts +7 -0
- package/dist/daemon/state-dir.js +59 -0
- package/dist/daemon/unclean-predecessor.d.ts +23 -0
- package/dist/daemon/unclean-predecessor.js +27 -0
- package/dist/events/predicate-eval.d.ts +17 -3
- package/dist/events/predicate-eval.js +183 -96
- package/dist/events/predicate-parse.d.ts +23 -0
- package/dist/events/predicate-parse.js +68 -0
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +17 -3
- package/dist/events/ring-buffer.d.ts +8 -0
- package/dist/events/ring-buffer.js +35 -17
- package/dist/flows/flow-replay.d.ts +1 -1
- package/dist/flows/flow-replay.js +19 -2
- package/dist/flows/flow-step-runners.js +2 -3
- package/dist/flows/flow-tools.js +10 -14
- package/dist/flows/role-drift-reason.d.ts +20 -0
- package/dist/flows/role-drift-reason.js +39 -0
- package/dist/flows/suite-flakes.d.ts +33 -0
- package/dist/flows/suite-flakes.js +32 -0
- package/dist/honesty/blind-spots.d.ts +19 -0
- package/dist/honesty/blind-spots.js +33 -0
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +9 -1
- package/dist/honesty/verified.d.ts +10 -1
- package/dist/honesty/verified.js +42 -3
- package/dist/http-server.js +20 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +36 -3
- package/dist/init/agent-rules.d.ts +18 -0
- package/dist/init/agent-rules.js +28 -5
- package/dist/init/cra.d.ts +13 -0
- package/dist/init/cra.js +16 -0
- package/dist/init/detect.d.ts +14 -0
- package/dist/init/detect.js +23 -0
- package/dist/init/mcp-clients.d.ts +81 -0
- package/dist/init/mcp-clients.js +248 -0
- package/dist/init/node-io.js +8 -2
- 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 +55 -3
- package/dist/init/plan.d.ts +14 -0
- package/dist/init/plan.js +93 -12
- package/dist/init/run.d.ts +18 -1
- package/dist/init/run.js +169 -23
- package/dist/init/snippets.d.ts +20 -1
- package/dist/init/snippets.js +71 -6
- package/dist/init/windows-quote.d.ts +38 -0
- package/dist/init/windows-quote.js +70 -0
- package/dist/mcp/mcp-outage.d.ts +1 -0
- package/dist/mcp/mcp-outage.js +8 -1
- package/dist/mcp/mcp-proxy.d.ts +11 -1
- package/dist/mcp/mcp-proxy.js +39 -9
- package/dist/mcp/mcp.js +64 -2
- package/dist/mcp/proxy-lifecycle.d.ts +19 -0
- package/dist/mcp/proxy-lifecycle.js +21 -0
- package/dist/pool/browser-pool.d.ts +9 -0
- package/dist/pool/browser-pool.js +38 -9
- package/dist/pool/playwright-launcher.js +10 -2
- package/dist/session/attachment-history.d.ts +46 -0
- package/dist/session/attachment-history.js +65 -0
- 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 +11 -4
- package/dist/session/no-session-diagnosis.js +95 -14
- package/dist/session/no-session-watch.d.ts +3 -1
- package/dist/session/no-session-watch.js +10 -2
- package/dist/session/pending-commands.js +1 -0
- package/dist/session/review-store.d.ts +19 -0
- package/dist/session/review-store.js +34 -5
- package/dist/session/session-info.d.ts +17 -1
- package/dist/session/session-info.js +5 -1
- package/dist/session/session-manager.d.ts +12 -0
- package/dist/session/session-manager.js +83 -3
- package/dist/session/session.d.ts +9 -0
- package/dist/session/session.js +20 -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/crash-cause.d.ts +47 -0
- package/dist/telemetry/crash-cause.js +104 -0
- package/dist/telemetry/daemon-telemetry.d.ts +25 -7
- package/dist/telemetry/daemon-telemetry.js +32 -13
- package/dist/telemetry/error-class.d.ts +26 -0
- package/dist/telemetry/error-class.js +52 -0
- package/dist/telemetry/feedback-cli.d.ts +7 -2
- package/dist/telemetry/feedback-cli.js +49 -5
- package/dist/telemetry/feedback-context.d.ts +1 -6
- package/dist/telemetry/feedback-context.js +64 -4
- package/dist/telemetry/git-facts.d.ts +7 -0
- package/dist/telemetry/git-facts.js +4 -4
- package/dist/telemetry/project-profile.js +2 -1
- package/dist/telemetry/session-metrics.d.ts +45 -3
- package/dist/telemetry/session-metrics.js +247 -9
- package/dist/telemetry/telemetry.d.ts +21 -1
- package/dist/telemetry/telemetry.js +34 -9
- package/dist/telemetry/verification-of.js +26 -1
- package/dist/tools/act-tools.js +63 -14
- package/dist/tools/assert-verdict.d.ts +7 -1
- package/dist/tools/assert-verdict.js +22 -3
- package/dist/tools/browser-tools.js +8 -2
- package/dist/tools/contract-tools.d.ts +0 -5
- package/dist/tools/contract-tools.js +26 -0
- package/dist/tools/dynamic-tools.js +6 -1
- package/dist/tools/error-recovery.d.ts +14 -0
- package/dist/tools/error-recovery.js +89 -8
- package/dist/tools/feedback-invite.d.ts +48 -0
- package/dist/tools/feedback-invite.js +71 -0
- package/dist/tools/invoke-tool.d.ts +0 -6
- package/dist/tools/invoke-tool.js +76 -7
- package/dist/tools/navigate-arrival.d.ts +26 -0
- package/dist/tools/navigate-arrival.js +60 -0
- package/dist/tools/navigate-result.d.ts +15 -1
- package/dist/tools/navigate-result.js +17 -10
- package/dist/tools/observe-tools.js +22 -4
- package/dist/tools/real-input-attempt.js +10 -5
- package/dist/tools/tool-kit.d.ts +60 -5
- package/dist/tools/tool-kit.js +55 -24
- package/dist/tools/tool-surface.js +13 -0
- 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/README.md +55 -0
- package/docs/agent-cheatsheet.md +15 -3
- package/docs/benchmarks.md +1 -1
- package/docs/fixtures-dispatch-receiver.yml +76 -0
- package/docs/fixtures.md +17 -0
- package/docs/gate-plan.md +98 -0
- package/docs/gates.md +117 -0
- package/docs/local-registry.md +1 -1
- package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
- package/docs/matrix/2.5.0/codex-machine.json +20 -0
- package/docs/matrix/2.5.0/cursor-machine.json +22 -0
- package/docs/matrix/2.5.0/gemini-machine.json +22 -0
- package/docs/matrix/2.5.0/opencode-machine.json +22 -0
- package/docs/matrix/2.5.0/vscode-machine.json +22 -0
- package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
- package/docs/matrix/MATRIX.md +26 -0
- package/docs/matrix/README.md +59 -0
- package/docs/mint.json +1 -1
- package/docs/system-map.md +152 -0
- package/docs/telemetry-contract.md +15 -3
- package/docs/telemetry.md +16 -1
- package/docs/token-efficiency.md +2 -2
- package/package.json +2 -2
package/dist/tools/act-tools.js
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { aliasParam } from './alias-args.js';
|
|
8
8
|
import { captureAct, compileSequenceStep } from '../flows/replay.js';
|
|
9
|
-
import { ActionType, ActionWarning, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, PredicateKind, } from '@reticlehq/core';
|
|
9
|
+
import { ActionType, ActionWarning, CaptureLoss, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, PredicateKind, } from '@reticlehq/core';
|
|
10
10
|
import { assertNativeInputSupported } from './act-danger.js';
|
|
11
11
|
import { leanActResult, mutatedWithin } from './act-view.js';
|
|
12
12
|
import { ReticleTool } from './tool-names.js';
|
|
13
13
|
import { buildReactionReport, summarizeReaction } from '../events/reaction.js';
|
|
14
|
+
import { parsePredicate } from '../events/predicate-parse.js';
|
|
14
15
|
import { causalSummary } from '../capsule/causal-summary.js';
|
|
15
16
|
import { findContradictions } from '../events/contradictions.js';
|
|
16
17
|
import { decideVerified } from '../honesty/verified.js';
|
|
@@ -19,7 +20,7 @@ import { saveFailedAssertCapsule } from './act-capsule.js';
|
|
|
19
20
|
import { buildDivergenceCapsule } from '../capsule/capsule.js';
|
|
20
21
|
import { predicateToExpectedLinks } from '../capsule/predicate-to-links.js';
|
|
21
22
|
import { buildHonestyBlock } from '../honesty/honesty.js';
|
|
22
|
-
import { buildCoverageStatement, blindSpotsFromState, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
|
|
23
|
+
import { buildCoverageStatement, blindSpotsFromState, transportGapNote, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
|
|
23
24
|
import { hasAcceptedWrite } from '../honesty/accepted-write.js';
|
|
24
25
|
import { hasUnreadWriteOutcome } from '../honesty/unread-outcome.js';
|
|
25
26
|
import { evaluatePredicate, waitForPredicate, provenExpectedLinks, PredicateSchema, } from '../events/predicate.js';
|
|
@@ -54,7 +55,7 @@ export const ACT_TOOLS = [
|
|
|
54
55
|
{
|
|
55
56
|
name: ReticleTool.ACT,
|
|
56
57
|
example: { ref: 'e42', action: 'fill', args: { value: 'hello' } },
|
|
57
|
-
description: '
|
|
58
|
+
description: 'Act WITHOUT checking the result — if the action is supposed to cause something, use reticle_act_and_wait { until } instead and get the verdict in the same call. This tool returns immediately with a `since` cursor; observe the reaction yourself with reticle_observe. Use it for actions with no observable consequence to assert (focus, hover, scrollIntoView, a fill you are about to submit). One action against a ref: click|dblclick|hover|focus|fill|type|clear|select|check|uncheck|submit|press|scrollIntoView. Carries effect:{dispatched,targetMatched,visible,enabled,focusMoved,valueChanged,domMutatedWithin,occluded,occludedBy,scrolledIntoView} to tell "action missed" from "app didn\'t react"; dispatched=landed, settled=a real frame flushed, and a settle timeout never fails the tool. Fields at their uninformative default are OMITTED so a clean action collapses to its consequence: an absent dispatched/targetMatched/visible/enabled means true, an absent occluded/scrolledIntoView/valueChanged/defaultPrevented means false, an absent focusMoved/occludedBy means null. occluded=true means the click point is covered by another element (a real user could not click it) — synthetic dispatch still delivered the event; scrolledIntoView=true means an off-viewport target was scrolled in first. inputMode is "real" (native CDP, no synthetic effect block) or "synthetic"; clicks default to the occlusion-honest synthetic path even when CDP is configured — pass args.native:true to force a trusted native click (file pickers, clipboard). args.holdMs keeps the pointer DOWN for that long between mousedown and mouseup — the only way to drive a hold-to-confirm control (hold-to-delete, hold-to-record, long-press); effect.heldMs reports the hold actually achieved, which a throttled background tab can stretch. args.confirmDangerous is a permission gate and NOT a duration: it allows a destructive control, it does not hold one. inputModeReason explains any real→synthetic choice so it is never silent. Full model (real-input, throttled tabs, `reticle drive`): node_modules/@reticlehq/server/docs/usage.md §18.',
|
|
58
59
|
inputSchema: {
|
|
59
60
|
ref: z
|
|
60
61
|
.string()
|
|
@@ -63,7 +64,7 @@ export const ACT_TOOLS = [
|
|
|
63
64
|
args: z
|
|
64
65
|
.record(z.unknown())
|
|
65
66
|
.optional()
|
|
66
|
-
.describe('Action-specific arguments: { value } for fill/select, { text } for type/press, { native: true } to force a trusted native click, { confirmDangerous: true } to allow a potentially destructive control.'),
|
|
67
|
+
.describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { native: true } to force a trusted native click, { holdMs: N } to keep the pointer DOWN for N ms (hold-to-confirm controls; effect.heldMs reports what was achieved), { confirmDangerous: true } to allow a potentially destructive control — a permission gate, NOT a duration.'),
|
|
67
68
|
refuseWhenThrottled: z
|
|
68
69
|
.boolean()
|
|
69
70
|
.optional()
|
|
@@ -172,7 +173,18 @@ export const ACT_TOOLS = [
|
|
|
172
173
|
},
|
|
173
174
|
{
|
|
174
175
|
name: ReticleTool.ACT_SEQUENCE,
|
|
175
|
-
|
|
176
|
+
// The example is required for a core tool, and this one carries weight: the measured loop it
|
|
177
|
+
// replaces is literally a login form driven as three separate reticle_act calls (98 clicks and
|
|
178
|
+
// 21 fills inside looping sessions, 2026-08-10/11). Showing fill -> fill -> click is showing the
|
|
179
|
+
// exact shape an agent otherwise spends three round trips on.
|
|
180
|
+
example: {
|
|
181
|
+
steps: [
|
|
182
|
+
{ ref: 'e12', action: 'fill', args: { value: 'a@b.com' } },
|
|
183
|
+
{ ref: 'e13', action: 'fill', args: { value: 'hunter2' } },
|
|
184
|
+
{ ref: 'e14', action: 'click' },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
description: 'Run multiple actions in order (fill -> fill -> submit) in ONE round-trip. Prefer this over repeating reticle_act for a multi-step journey, then assert its consequence once. Returns per-step effects[] (see reticle_act).',
|
|
176
188
|
inputSchema: {
|
|
177
189
|
steps: z
|
|
178
190
|
.array(z.record(z.unknown()))
|
|
@@ -243,7 +255,7 @@ export const ACT_TOOLS = [
|
|
|
243
255
|
args: z
|
|
244
256
|
.record(z.unknown())
|
|
245
257
|
.optional()
|
|
246
|
-
.describe('Action-specific arguments: { value } for fill/select, { text } for type/press, { confirmDangerous: true } for a potentially destructive control.'),
|
|
258
|
+
.describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { confirmDangerous: true } for a potentially destructive control.'),
|
|
247
259
|
predicate: PredicateSchema.optional().describe('Alias for `until` (the name reticle_assert / reticle_wait_for use).'),
|
|
248
260
|
until: PredicateSchema.optional().describe('Predicate to wait for after the action completes (same shape as reticle_assert). OMIT to wait for the page to SETTLE — network + DOM idle — the deterministic default instead of a sleep. To assert a consequence AND settle, allOf them: { kind: "allOf", predicates: [<your predicate>, { kind: "settled" }] }.'),
|
|
249
261
|
timeout_ms: z
|
|
@@ -264,6 +276,10 @@ export const ACT_TOOLS = [
|
|
|
264
276
|
pass: z.boolean(),
|
|
265
277
|
evidence: z.unknown().optional(),
|
|
266
278
|
failureReason: z.string().optional(),
|
|
279
|
+
observationLost: z
|
|
280
|
+
.boolean()
|
|
281
|
+
.optional()
|
|
282
|
+
.describe('The tab disconnected mid-wait, so this was never observed — the verdict is UNKNOWN, not a failure of the app.'),
|
|
267
283
|
// The STRUCTURED cause — observed / expected / assertion — is what the repair literature ranks
|
|
268
284
|
// above the prose failureReason (structured feedback beat narrative by 10.5pp) and above a bare
|
|
269
285
|
// pointer. `verdict` is `await waitForPredicate(...)`, whose EvalResult carries these on a
|
|
@@ -332,18 +348,13 @@ export const ACT_TOOLS = [
|
|
|
332
348
|
// `predicate` is what reticle_assert / reticle_wait_for call this — see alias-args.ts.
|
|
333
349
|
const withUntil = aliasParam(args, 'until', ['predicate']);
|
|
334
350
|
const until = withUntil['until'] !== undefined
|
|
335
|
-
?
|
|
351
|
+
? parsePredicate(withUntil['until'])
|
|
336
352
|
: { kind: PredicateKind.SETTLED };
|
|
337
353
|
const timeout = asNumber(args['timeout_ms']) ?? DEFAULT_ASSERT_TIMEOUT_MS;
|
|
338
354
|
// Before anything is driven: this path cannot honour a native-input request, and taking the
|
|
339
355
|
// argument and ignoring it told the agent its trusted click had happened. See act-danger.
|
|
340
356
|
assertNativeInputSupported(asRecord(args['args']));
|
|
341
357
|
const since = session.elapsed();
|
|
342
|
-
// `dropped` is cumulative for the SESSION, so comparing against a pre-action reading is what
|
|
343
|
-
// asks "did the buffer lose anything while THIS action was observed?" — the question the
|
|
344
|
-
// honesty block means. Reading the raw total pinned every later verdict to `unknown` after a
|
|
345
|
-
// single early eviction (measured on the Next.js demo at dropped:51, windows intact).
|
|
346
|
-
const droppedBefore = session.bufferHealth().dropped;
|
|
347
358
|
// The cursor + effect are marked after the act dispatches (below) — a refused act leaves none.
|
|
348
359
|
// The attribution window stays open across the settle wait below, so post-dispatch async events
|
|
349
360
|
// (the whole point of act_and_wait) attribute to this action. finishAction fires after the wait.
|
|
@@ -404,12 +415,35 @@ export const ACT_TOOLS = [
|
|
|
404
415
|
// structural boundary (virtualized rows, a cross-origin frame) is reported as coverage and
|
|
405
416
|
// must not downgrade a verdict about what WAS observed.
|
|
406
417
|
const impeaching = buildCoverageStatement(spots.filter((s) => impeachesCapture(s.kind)));
|
|
418
|
+
// Same rule as reticle_assert: a browser-side transport gap means part of this window was
|
|
419
|
+
// never seen, which is what `blindSpots` exists to say. `truncated` above covers the SERVER
|
|
420
|
+
// ring buffer evicting; this covers the BROWSER queue overflowing, and they are not the same
|
|
421
|
+
// loss — an act_and_wait that graded `proved` over 34 dropped events said so in a sentence
|
|
422
|
+
// whose own words were "over a clean capture".
|
|
423
|
+
const gapNote = transportGapNote(windowEvents);
|
|
424
|
+
const impeachingNotes = [impeaching.note, gapNote].filter((n) => n !== undefined);
|
|
425
|
+
const bufferLost = session.lostSince(since);
|
|
426
|
+
// Which loss, as an enum, beside the prose that describes it. Classified here because this is
|
|
427
|
+
// the only place that knows the three apart: our buffer, our transport, and the page's own
|
|
428
|
+
// boundaries. See `CaptureLoss`.
|
|
429
|
+
const losses = [
|
|
430
|
+
...(bufferLost ? [CaptureLoss.BUFFER_LOSS] : []),
|
|
431
|
+
...(gapNote === undefined ? [] : [CaptureLoss.TRANSPORT_GAP]),
|
|
432
|
+
...(impeaching.note === undefined ? [] : [CaptureLoss.BLIND_SPOT]),
|
|
433
|
+
];
|
|
407
434
|
const honesty = buildHonestyBlock({
|
|
408
435
|
grade: gradeOf(gradedLinks),
|
|
409
436
|
attribution: 'window',
|
|
410
|
-
|
|
437
|
+
// Did the buffer lose scarce evidence FROM THIS WINDOW — not "did it evict anything while
|
|
438
|
+
// the action ran", which was the previous rule and which is true on essentially every live
|
|
439
|
+
// page. Age eviction retires everything past 60s on every push, and the churn floor is
|
|
440
|
+
// sacrificed on purpose; neither costs this verdict a single event it needed. In the
|
|
441
|
+
// field this made `unclean_capture` the dominant cause of `unknown`, reported for
|
|
442
|
+
// evictions that happened outside the window they impeached.
|
|
443
|
+
truncated: bufferLost,
|
|
411
444
|
coveragePartial: Coverage.PARTIAL === coverage.coverage,
|
|
412
|
-
...(
|
|
445
|
+
...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
|
|
446
|
+
losses,
|
|
413
447
|
});
|
|
414
448
|
const capsuleSaved = await saveFailedAssertCapsule({
|
|
415
449
|
deps,
|
|
@@ -423,8 +457,19 @@ export const ACT_TOOLS = [
|
|
|
423
457
|
// Pass the action: an EMPTY window then reads as "the target does not react" rather than as a
|
|
424
458
|
// clean run — `settled` is satisfied by a page that did nothing, i.e. by a click that missed.
|
|
425
459
|
const acted = asString(args['action']);
|
|
460
|
+
// `prior` matters here for the same reason it matters on the assert path: a SCALE error
|
|
461
|
+
// disagrees with a value the API stated EARLIER in the session, which an action-scoped
|
|
462
|
+
// window can never contain. Without it `unit-mismatch` — the money false green, where a
|
|
463
|
+
// total renders 100x off — is structurally unreachable from `act_and_wait`, which is the
|
|
464
|
+
// tool the product tells agents to reach for first. Our best detector could not fire on our
|
|
465
|
+
// flagship path, and nothing said so: the finding was simply never produced.
|
|
466
|
+
//
|
|
467
|
+
// Learning material only. Attribution is unchanged — findings still come exclusively from
|
|
468
|
+
// `windowEvents`, exactly as on the assert path.
|
|
469
|
+
const prior = since > 0 ? (await session.queryEvents({ since: 0 })).filter((e) => e.t < since) : [];
|
|
426
470
|
const contradictions = findContradictions(windowEvents, {
|
|
427
471
|
action: acted,
|
|
472
|
+
prior,
|
|
428
473
|
...session.lastAct.effect(),
|
|
429
474
|
});
|
|
430
475
|
// The single field an agent reads. Everything below it is the evidence it was derived from;
|
|
@@ -436,6 +481,10 @@ export const ACT_TOOLS = [
|
|
|
436
481
|
...(alreadyTrue ? { alreadyTrue } : {}),
|
|
437
482
|
// An assertion nobody could evaluate must not be reported as one the app failed.
|
|
438
483
|
...(verdict.inconclusive === undefined ? {} : { inconclusive: verdict.inconclusive }),
|
|
484
|
+
// Nor must one nobody could OBSERVE. This is the act path, so it is the one that produced
|
|
485
|
+
// the measured false red: a reload mid-wait, graded assertion_failed at the clicked
|
|
486
|
+
// component's own file and line.
|
|
487
|
+
...(true === verdict.observationLost ? { observationLost: true } : {}),
|
|
439
488
|
honesty,
|
|
440
489
|
contradictions,
|
|
441
490
|
...(outcomePending ? { outcomePending } : {}),
|
|
@@ -16,7 +16,13 @@ import { type Contradiction } from '../events/contradictions.js';
|
|
|
16
16
|
*/
|
|
17
17
|
export declare function assertVerdict(session: Session, predicate: Predicate, pass: boolean, since: number,
|
|
18
18
|
/** Set when the assertion was never evaluated — see honesty/verified.ts. */
|
|
19
|
-
inconclusive?: string
|
|
19
|
+
inconclusive?: string,
|
|
20
|
+
/**
|
|
21
|
+
* Set when the tab went away mid-wait, so the assertion was never OBSERVED. `reticle_assert` and
|
|
22
|
+
* `reticle_wait_for` reach the same disconnect path as the act tools, and a verdict that blames
|
|
23
|
+
* the app for a lost connection is no more honest on this route than on that one.
|
|
24
|
+
*/
|
|
25
|
+
observationLost?: boolean): Promise<{
|
|
20
26
|
decision: Record<string, unknown>;
|
|
21
27
|
contradictions: Contradiction[];
|
|
22
28
|
coverage: Record<string, unknown>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { CaptureLoss } from '@reticlehq/core';
|
|
1
2
|
import { findContradictions } from '../events/contradictions.js';
|
|
2
|
-
import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
|
|
3
|
+
import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, transportGapNote, } from '../honesty/blind-spots.js';
|
|
3
4
|
import { buildHonestyBlock } from '../honesty/honesty.js';
|
|
4
5
|
import { hasAcceptedWrite } from '../honesty/accepted-write.js';
|
|
5
6
|
import { hasUnreadWriteOutcome } from '../honesty/unread-outcome.js';
|
|
@@ -20,7 +21,13 @@ import { gradeOfPredicate } from './assert-grade.js';
|
|
|
20
21
|
*/
|
|
21
22
|
export async function assertVerdict(session, predicate, pass, since,
|
|
22
23
|
/** Set when the assertion was never evaluated — see honesty/verified.ts. */
|
|
23
|
-
inconclusive
|
|
24
|
+
inconclusive,
|
|
25
|
+
/**
|
|
26
|
+
* Set when the tab went away mid-wait, so the assertion was never OBSERVED. `reticle_assert` and
|
|
27
|
+
* `reticle_wait_for` reach the same disconnect path as the act tools, and a verdict that blames
|
|
28
|
+
* the app for a lost connection is no more honest on this route than on that one.
|
|
29
|
+
*/
|
|
30
|
+
observationLost) {
|
|
24
31
|
// Scope caveat, stated because it is a real limitation and not a bug: blind spots are tracked per
|
|
25
32
|
// SESSION, not per assertion window, so a cross-origin iframe seen once marks every later verdict
|
|
26
33
|
// in that session partial — including ones about a region it cannot affect. That errs toward
|
|
@@ -46,16 +53,28 @@ inconclusive) {
|
|
|
46
53
|
// Only a spot that IMPEACHES the capture downgrades a verdict. A structural boundary (virtualized
|
|
47
54
|
// rows, a cross-origin frame) is reported as coverage and must not impugn what WAS observed.
|
|
48
55
|
const impeaching = buildCoverageStatement(spots.filter((sp) => impeachesCapture(sp.kind)));
|
|
56
|
+
// A gap in the WINDOW, as opposed to a standing limit of the page. Both mean the same thing to the
|
|
57
|
+
// rule — part of what happened was not seen — so both belong in `blindSpots`, which is the only
|
|
58
|
+
// input `decideVerified` reads for that.
|
|
59
|
+
const gap = transportGapNote(windowEvents);
|
|
60
|
+
const impeachingNotes = [impeaching.note, gap].filter((n) => n !== undefined);
|
|
49
61
|
const outcomePending = hasAcceptedWrite(windowEvents);
|
|
50
62
|
const outcomeUnread = hasUnreadWriteOutcome(windowEvents);
|
|
51
63
|
const decision = decideVerified({
|
|
52
64
|
pass,
|
|
53
65
|
...(inconclusive === undefined ? {} : { inconclusive }),
|
|
66
|
+
...(true === observationLost ? { observationLost: true } : {}),
|
|
54
67
|
honesty: buildHonestyBlock({
|
|
55
68
|
grade: gradeOfPredicate(predicate),
|
|
56
69
|
attribution: 'window',
|
|
57
70
|
coveragePartial: Coverage.PARTIAL === statement.coverage,
|
|
58
|
-
...(
|
|
71
|
+
...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
|
|
72
|
+
// Which loss, as an enum, beside the prose. `assert` observes an already-open window and never
|
|
73
|
+
// consults the ring buffer's health, so `buffer_loss` is not one of its answers.
|
|
74
|
+
losses: [
|
|
75
|
+
...(gap === undefined ? [] : [CaptureLoss.TRANSPORT_GAP]),
|
|
76
|
+
...(impeaching.note === undefined ? [] : [CaptureLoss.BLIND_SPOT]),
|
|
77
|
+
],
|
|
59
78
|
}),
|
|
60
79
|
contradictions,
|
|
61
80
|
...(outcomePending ? { outcomePending } : {}),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { navigateResult } from './navigate-result.js';
|
|
3
|
+
import { awaitArrival } from './navigate-arrival.js';
|
|
3
4
|
import { reloadResult } from './reload-result.js';
|
|
4
5
|
import { waitForReconnect, RELOAD_RECONNECT_TIMEOUT_MS } from '../session/session-reconnect.js';
|
|
5
6
|
import { ReticleCommand } from '@reticlehq/core';
|
|
@@ -10,7 +11,7 @@ export const BROWSER_TOOLS = [
|
|
|
10
11
|
{
|
|
11
12
|
name: ReticleTool.NAVIGATE,
|
|
12
13
|
example: { url: '/settings' },
|
|
13
|
-
description: 'Navigate the connected browser tab to a URL, or reload it in place with { reload: true } (add { hard: true } to bypass the cache). `ok` means the navigation was DISPATCHED
|
|
14
|
+
description: 'Navigate the connected browser tab to a URL, or reload it in place with { reload: true } (add { hard: true } to bypass the cache). `ok` means the navigation was DISPATCHED — the SDK is torn down by the navigation, so the page itself cannot report on it. The daemon then waits briefly for the SDK to reconnect: `confirmed:true` with a new `sessionId` means the page arrived and you can act immediately. `confirmed:false` means it did not arrive within the window — the page may be slow, uninstrumented, or not there; check reticle_sessions before acting.',
|
|
14
15
|
inputSchema: {
|
|
15
16
|
url: z.string().optional().describe('The URL to navigate to. Omit when using reload.'),
|
|
16
17
|
reload: z
|
|
@@ -30,6 +31,8 @@ export const BROWSER_TOOLS = [
|
|
|
30
31
|
// Present on a dispatched navigation: nothing here can see the new document, so arrival is
|
|
31
32
|
// reported as unconfirmed rather than implied by `ok`. See navigate-result.ts.
|
|
32
33
|
confirmed: z.boolean().optional(),
|
|
34
|
+
/** The session the SDK reconnected as, when arrival was confirmed — it is a NEW id. */
|
|
35
|
+
sessionId: z.string().optional(),
|
|
33
36
|
note: z.string().optional(),
|
|
34
37
|
},
|
|
35
38
|
handler: async (deps, args) => {
|
|
@@ -65,7 +68,10 @@ export const BROWSER_TOOLS = [
|
|
|
65
68
|
try {
|
|
66
69
|
const result = (await commandOrThrow(deps, asString(args['sessionId']), ReticleCommand.NAVIGATE, { url }));
|
|
67
70
|
// `ok` is the browser accepting the instruction, not the page arriving — see navigate-result.
|
|
68
|
-
|
|
71
|
+
// The daemon is the only party that CAN see arrival (the SDK reconnects to it), so it looks,
|
|
72
|
+
// briefly, instead of telling the agent to go poll reticle_sessions itself.
|
|
73
|
+
const arrival = true === result.ok ? await awaitArrival(deps.sessions, url) : null;
|
|
74
|
+
return navigateResult(result, arrival);
|
|
69
75
|
}
|
|
70
76
|
finally {
|
|
71
77
|
session.finishAction();
|
|
@@ -1,7 +1,2 @@
|
|
|
1
1
|
import type { ToolDef } from './tools.js';
|
|
2
|
-
/**
|
|
3
|
-
* The capability-contract tools. `reticle_capabilities` reads the live session, or the
|
|
4
|
-
* git-checked `.reticle/contract.json` when `{ fromDisk:true }`; `reticle_contract_save` persists the
|
|
5
|
-
* live registry to that file (pretty-printed, stable key order — diffable in PRs).
|
|
6
|
-
*/
|
|
7
2
|
export declare const CONTRACT_TOOLS: ToolDef[];
|
|
@@ -9,6 +9,31 @@ import { reticleDirPaths, readContract, writeContract } from '../project/reticle
|
|
|
9
9
|
* git-checked `.reticle/contract.json` when `{ fromDisk:true }`; `reticle_contract_save` persists the
|
|
10
10
|
* live registry to that file (pretty-printed, stable key order — diffable in PRs).
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* Refuse to persist one project's surface into another project's repo.
|
|
14
|
+
*
|
|
15
|
+
* `writeContract` writes to the DAEMON's `.reticle/`, which is the directory the daemon was started
|
|
16
|
+
* in — not the session's project. A daemon started above several apps therefore saves app A's
|
|
17
|
+
* testids into app B's checkout, reports `saved: true`, and hands back a path that looks right. A
|
|
18
|
+
* contract is git-checked and diffable, so that is a change somebody commits.
|
|
19
|
+
*
|
|
20
|
+
* Refuse rather than redirect: a session advertises a projectId, not a project DIRECTORY, so there
|
|
21
|
+
* is no honest way to work out where the correct `.reticle/` lives. Naming both ids and stopping is
|
|
22
|
+
* the whole of what can be said truthfully.
|
|
23
|
+
*
|
|
24
|
+
* Silence is not a mismatch. An unstamped build carries no projectId, and a daemon above an
|
|
25
|
+
* uninitialised directory has none either; neither is evidence of two projects.
|
|
26
|
+
*/
|
|
27
|
+
function assertSameProject(deps, sessionId) {
|
|
28
|
+
const mine = deps.projectId;
|
|
29
|
+
const theirs = deps.sessions.resolve(sessionId).projectId;
|
|
30
|
+
if (mine === undefined || theirs === undefined || mine === theirs)
|
|
31
|
+
return;
|
|
32
|
+
throw new Error(`refusing to save: this session belongs to project '${theirs}', but this daemon writes to ` +
|
|
33
|
+
`project '${mine}' (.reticle/ in the directory it was started in). Saving would put one ` +
|
|
34
|
+
"app's testable surface into another app's git-checked contract. Run a daemon from " +
|
|
35
|
+
`'${theirs}'s own directory and save there.`);
|
|
36
|
+
}
|
|
12
37
|
export const CONTRACT_TOOLS = [
|
|
13
38
|
{
|
|
14
39
|
name: ReticleTool.CAPABILITIES,
|
|
@@ -54,6 +79,7 @@ export const CONTRACT_TOOLS = [
|
|
|
54
79
|
handler: async (deps, args) => {
|
|
55
80
|
const res = await commandOrThrow(deps, asString(args['sessionId']), ReticleCommand.CAPABILITIES, {});
|
|
56
81
|
const caps = CapabilitiesSchema.parse(res);
|
|
82
|
+
assertSameProject(deps, asString(args['sessionId']));
|
|
57
83
|
await writeContract(deps.fs, deps.reticleRoot, caps, deps.now);
|
|
58
84
|
return {
|
|
59
85
|
saved: true,
|
|
@@ -141,7 +141,12 @@ export function buildDynamicTools(allTools, profile) {
|
|
|
141
141
|
if (target === undefined) {
|
|
142
142
|
// A non-zero count here means our advertised surface is confusing the agent — it reached for
|
|
143
143
|
// something it believed existed. That is a docs/naming defect, and it was invisible.
|
|
144
|
-
|
|
144
|
+
//
|
|
145
|
+
// The NAME goes with it: the count says the surface confused someone, the name says which
|
|
146
|
+
// capability they expected. It was already in hand here and thrown away, so the one place
|
|
147
|
+
// the product could learn what agents want was discarding it. Safe — a name from our own
|
|
148
|
+
// tool namespace, never app data.
|
|
149
|
+
getSessionMetrics().recordUnknownTool(name);
|
|
145
150
|
// ...and for 22 of those names the capability DOES exist, it just moved when tools merged.
|
|
146
151
|
// Answering "unknown tool" there is simply wrong; see merged-name-redirect.
|
|
147
152
|
const moved = mergedNameRedirect(name);
|
|
@@ -40,6 +40,20 @@ export declare const RECOVERY: {
|
|
|
40
40
|
* saying a server is running and the recovery saying to go start one.
|
|
41
41
|
*/
|
|
42
42
|
export declare function recoveryFor(message: string): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* A serialized zod issue array, rendered as a sentence. Anything else is returned untouched.
|
|
45
|
+
*
|
|
46
|
+
* This is the one boundary every agent-visible tool failure crosses, which is why the rewrite lives
|
|
47
|
+
* here rather than in each tool. `parsePredicate` solved this for the three predicate tools by never
|
|
48
|
+
* producing the array; it cannot help anywhere else, because the MCP SDK validates a tool's schema
|
|
49
|
+
* BEFORE our handler runs. So `reticle_session` with no action — the commonest mistake there is —
|
|
50
|
+
* never reaches the friendly "unknown action … expected: [tune, yield, …]" its handler already
|
|
51
|
+
* builds, and the agent gets the raw array instead. Driven live against the shipped daemon.
|
|
52
|
+
*
|
|
53
|
+
* Conservative by construction: it only rewrites a JSON ARRAY whose every element carries both
|
|
54
|
+
* `code` and `message`. A tool that legitimately returns JSON is left alone.
|
|
55
|
+
*/
|
|
56
|
+
export declare function zodArrayAsSentence(message: string): string;
|
|
43
57
|
/**
|
|
44
58
|
* The ask attached to errors we do NOT recognize. An unrecognized error is, by definition, the case
|
|
45
59
|
* where Reticle failed in a way nobody anticipated — the single highest-value moment to hear from the
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { TRANSPORT_LIMITS } from '@reticlehq/core';
|
|
12
12
|
import { SELF_RECOVERING_MARKER } from '../session/no-session-diagnosis.js';
|
|
13
|
+
import { chromiumInstallCommand, bundledPlaywrightVersion } from '../cli/chromium-hint.js';
|
|
13
14
|
/** Marks where an over-long message had its middle removed. Named so tests assert it by value. */
|
|
14
15
|
const ELISION = '… [elided] …';
|
|
15
16
|
/**
|
|
@@ -40,7 +41,13 @@ function capMessage(message) {
|
|
|
40
41
|
* it was still collecting the defect ask, inviting a bug report about a refusal that had just told
|
|
41
42
|
* the agent precisely what to do.
|
|
42
43
|
*/
|
|
43
|
-
const SELF_RECOVERING = [
|
|
44
|
+
const SELF_RECOVERING = [
|
|
45
|
+
/cannot drive native input/i,
|
|
46
|
+
// The unknown-session refusal now lists the live ids inline. Appending "call reticle_sessions for
|
|
47
|
+
// the current ids" to a message that just named them contradicts the shorter path it offers, and
|
|
48
|
+
// a contradicted instruction is how one agent came to retry a dead id twelve times.
|
|
49
|
+
/Connected right now:/i,
|
|
50
|
+
];
|
|
44
51
|
/** A message that already carries its own concrete next action, so nothing should be appended. */
|
|
45
52
|
function isSelfRecovering(message) {
|
|
46
53
|
if (message.includes(SELF_RECOVERING_MARKER))
|
|
@@ -123,7 +130,11 @@ export const RECOVERY = {
|
|
|
123
130
|
'where it actually is. This is app/flow drift, which is exactly what the flow exists to catch.',
|
|
124
131
|
NO_POOL: 'Reticle cannot launch its own browser here: the daemon-managed pool is absent, or Playwright ' +
|
|
125
132
|
'has no Chromium installed (the message above says which). Run Reticle via `reticle mcp`, and ' +
|
|
126
|
-
|
|
133
|
+
// Pinned to the bundled playwright: the unpinned command installs a revision this daemon never
|
|
134
|
+
// looks at, so the human runs it, it succeeds, and nothing changes.
|
|
135
|
+
'ask the human for `' +
|
|
136
|
+
chromiumInstallCommand(bundledPlaywrightVersion()) +
|
|
137
|
+
'` if that is what is missing. Meanwhile drive ' +
|
|
127
138
|
'a tab the human already has open — reticle_sessions lists them.',
|
|
128
139
|
};
|
|
129
140
|
/** Ordered match rules; the first hit wins. Substrings track the thrown messages they recover. */
|
|
@@ -167,8 +178,13 @@ const RULES = [
|
|
|
167
178
|
match: /potentially destructive (?:\w+ )*(?:action|tool) blocked/i,
|
|
168
179
|
hint: RECOVERY.CONFIRM_DANGEROUS,
|
|
169
180
|
},
|
|
181
|
+
// Split in two because `^` binds to the FIRST alternative only. As one pattern this read as though
|
|
182
|
+
// the anchor governed all four spellings; it never did, and the next person to add a fifth would
|
|
183
|
+
// have had a coin-flip's chance of getting the behaviour they intended. The anchored spelling is
|
|
184
|
+
// one Reticle authors at the start of its own message; the other three appear mid-message.
|
|
185
|
+
{ match: /^cannot [\w()]+ (?:a|an|into a|on a) </i, hint: RECOVERY.WRONG_TARGET },
|
|
170
186
|
{
|
|
171
|
-
match:
|
|
187
|
+
match: /no form to submit|upload target must be|is not an HTMLElement/i,
|
|
172
188
|
hint: RECOVERY.WRONG_TARGET,
|
|
173
189
|
},
|
|
174
190
|
// Authored by Reticle, about the caller's arguments: the message already names the valid answers.
|
|
@@ -205,8 +221,74 @@ export function recoveryFor(message) {
|
|
|
205
221
|
*
|
|
206
222
|
* Matched by the SHAPES the validators actually produce (zod's codes, and the MCP layer's wrapper),
|
|
207
223
|
* because these are our own machinery talking, not the app.
|
|
224
|
+
*
|
|
225
|
+
* `did not parse` is the shape `parsePredicate` produces. It was added because removing the raw zod
|
|
226
|
+
* array from that path ALSO removed the codes this pattern keys on — so a malformed predicate
|
|
227
|
+
* stopped being recognised as the agent's mistake and started being blamed on Reticle, complete with
|
|
228
|
+
* a request for a bug report. The e2e battery caught it (`no bad argument is blamed on Reticle`);
|
|
229
|
+
* no unit test could, which is why one now exists below.
|
|
230
|
+
*/
|
|
231
|
+
/** Max issues rendered — a union rejection can emit one per member, which is how the array became unreadable. */
|
|
232
|
+
const MAX_RENDERED_ISSUES = 3;
|
|
233
|
+
function isZodIssue(value) {
|
|
234
|
+
if (typeof value !== 'object' || null === value)
|
|
235
|
+
return false;
|
|
236
|
+
const v = value;
|
|
237
|
+
return 'string' === typeof v['code'] && 'string' === typeof v['message'];
|
|
238
|
+
}
|
|
239
|
+
/** `["net","urlContains"]` → `net.urlContains`; an empty path means the object itself. */
|
|
240
|
+
function issueClause(issue) {
|
|
241
|
+
if ('unrecognized_keys' === issue.code && Array.isArray(issue.keys)) {
|
|
242
|
+
return `unknown field ${issue.keys.map(String).join(', ')}`;
|
|
243
|
+
}
|
|
244
|
+
const path = issue.path ?? [];
|
|
245
|
+
return `${0 === path.length ? 'the arguments' : path.map(String).join('.')}: ${issue.message}`;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* A serialized zod issue array, rendered as a sentence. Anything else is returned untouched.
|
|
249
|
+
*
|
|
250
|
+
* This is the one boundary every agent-visible tool failure crosses, which is why the rewrite lives
|
|
251
|
+
* here rather than in each tool. `parsePredicate` solved this for the three predicate tools by never
|
|
252
|
+
* producing the array; it cannot help anywhere else, because the MCP SDK validates a tool's schema
|
|
253
|
+
* BEFORE our handler runs. So `reticle_session` with no action — the commonest mistake there is —
|
|
254
|
+
* never reaches the friendly "unknown action … expected: [tune, yield, …]" its handler already
|
|
255
|
+
* builds, and the agent gets the raw array instead. Driven live against the shipped daemon.
|
|
256
|
+
*
|
|
257
|
+
* Conservative by construction: it only rewrites a JSON ARRAY whose every element carries both
|
|
258
|
+
* `code` and `message`. A tool that legitimately returns JSON is left alone.
|
|
259
|
+
*/
|
|
260
|
+
export function zodArrayAsSentence(message) {
|
|
261
|
+
const text = message.trim();
|
|
262
|
+
if (!text.startsWith('['))
|
|
263
|
+
return message;
|
|
264
|
+
let parsed;
|
|
265
|
+
try {
|
|
266
|
+
parsed = JSON.parse(text);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return message; // not JSON at all — e.g. prose that happens to open with a bracket
|
|
270
|
+
}
|
|
271
|
+
if (!Array.isArray(parsed) || 0 === parsed.length || !parsed.every(isZodIssue))
|
|
272
|
+
return message;
|
|
273
|
+
const shown = parsed.slice(0, MAX_RENDERED_ISSUES).map(issueClause).join('; ');
|
|
274
|
+
const rest = parsed.length - MAX_RENDERED_ISSUES;
|
|
275
|
+
return rest > 0 ? `${shown} (and ${String(rest)} more)` : shown;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* `unknown field` and `<path>: Required` are the shapes `zodArrayAsSentence` produces.
|
|
279
|
+
*
|
|
280
|
+
* Added for the same reason `did not parse` was: rendering the array as a sentence REMOVES the very
|
|
281
|
+
* codes (`invalid_type`, `unrecognized_keys`) this pattern matched on, so a schema rejection fell
|
|
282
|
+
* through to the generic branch and came back as "may be a defect in Reticle" — worse than the dump
|
|
283
|
+
* it replaced, because it also asks for a bug report about the agent's own malformed call. This repo
|
|
284
|
+
* has now made that mistake twice; the second time a test caught it before CI did.
|
|
285
|
+
*
|
|
286
|
+
* Widened a THIRD time for PR #182, which formats the MCP SDK's validation error at the argument
|
|
287
|
+
* boundary — "Missing required parameter for reticle_session: action (one of: …)". That is better
|
|
288
|
+
* prose than the field-level sentence below (it names the TOOL and the allowed VALUES) and it was
|
|
289
|
+
* authored before this formatter existed, so it must not be punished for arriving first.
|
|
208
290
|
*/
|
|
209
|
-
const ARGUMENT_REJECTION = /Unrecognized key|unrecognized_keys|invalid_type|Invalid arguments for tool|Unknown parameters? for|^Required |Expected \w+, received/i;
|
|
291
|
+
const ARGUMENT_REJECTION = /Unrecognized key|unrecognized_keys|invalid_type|did not parse|unknown field|: Required\b|Missing required parameter|Invalid parameter for|Invalid arguments for tool|Unknown parameters? for|^Required |Expected \w+, received/i;
|
|
210
292
|
/**
|
|
211
293
|
* What to say instead of asking for a bug report. The schema already stated the problem precisely;
|
|
212
294
|
* this only says who can fix it and where to look.
|
|
@@ -237,10 +319,9 @@ export const FEEDBACK_ASK = 'This error is not one Reticle recognizes, which mea
|
|
|
237
319
|
* agent always gets exactly one next move.
|
|
238
320
|
*/
|
|
239
321
|
export function buildErrorPayload(rawMessage) {
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
const message = capMessage(rawMessage);
|
|
322
|
+
// Render the zod array BEFORE capping: the cap would otherwise truncate the JSON mid-issue and
|
|
323
|
+
// leave an unparseable fragment as the agent's error text — the worst of both shapes.
|
|
324
|
+
const message = capMessage(zodArrayAsSentence(rawMessage));
|
|
244
325
|
// A self-diagnosing message gets NEITHER a generic recovery nor the feedback ask. It already
|
|
245
326
|
// inspected the machine and named the cause; a second, contradictory hint is noise, and inviting a
|
|
246
327
|
// bug report about a condition Reticle diagnosed itself is exactly backwards.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one line that tells an agent it can push back — worded for what just happened.
|
|
3
|
+
*
|
|
4
|
+
* Feedback is the highest-yield channel this product has: **14 reports over two days produced
|
|
5
|
+
* roughly 11 of the fixes in 2.6.0**, against ~6,100 numeric events that produced a funnel which
|
|
6
|
+
* turned out to be broken. Prose from an agent at the moment of friction beats any structured field
|
|
7
|
+
* we could add.
|
|
8
|
+
*
|
|
9
|
+
* It reached those 14 through a standing instruction in the MCP handshake — paid once at connect.
|
|
10
|
+
* The gap that leaves is the agent who hits something mid-task and is concentrating on the problem
|
|
11
|
+
* rather than on remembering that a feedback tool exists.
|
|
12
|
+
*
|
|
13
|
+
* So: invite at the moment of friction, and vary the wording. A fixed string repeated forty times
|
|
14
|
+
* is invisible by call five; a line that names what just went wrong is read because it is about the
|
|
15
|
+
* thing the agent is already looking at. ~12 tokens against a ~4,546 tok/turn surface.
|
|
16
|
+
*
|
|
17
|
+
* Every invite is counted (`feedbackPrompted`). If `feedback_submitted ÷ feedbackPrompted` stays
|
|
18
|
+
* flat, this is decoration and should be deleted — instrumenting our own nudge is the difference
|
|
19
|
+
* between a designed system and a hope.
|
|
20
|
+
*/
|
|
21
|
+
/** What just happened, as far as the chokepoint can tell. Ordered most specific first. */
|
|
22
|
+
export declare const FrictionKind: {
|
|
23
|
+
/** A tool that does not exist — the agent named a capability it expected us to have. */
|
|
24
|
+
readonly UNKNOWN_TOOL: "unknown_tool";
|
|
25
|
+
/** A verdict came back `unknown`: we drove the app and could not tell what happened. */
|
|
26
|
+
readonly UNKNOWN_VERDICT: "unknown_verdict";
|
|
27
|
+
/** The same tool, several times in a row. */
|
|
28
|
+
readonly REPEATING: "repeating";
|
|
29
|
+
/** The tool refused or errored. */
|
|
30
|
+
readonly REFUSED: "refused";
|
|
31
|
+
};
|
|
32
|
+
export type FrictionKind = (typeof FrictionKind)[keyof typeof FrictionKind];
|
|
33
|
+
/**
|
|
34
|
+
* Which friction, if any, this result represents.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately reads only what the chokepoint already has: no handler changes, and nothing here can
|
|
37
|
+
* see app data.
|
|
38
|
+
*/
|
|
39
|
+
export declare function frictionOf(facts: {
|
|
40
|
+
unknownTool: boolean;
|
|
41
|
+
verifiedUnknown: boolean;
|
|
42
|
+
repeatRun: number;
|
|
43
|
+
errored: boolean;
|
|
44
|
+
/** Did this call produce a verdict? A verdict is progress, and progress is not being stuck. */
|
|
45
|
+
producedVerdict: boolean;
|
|
46
|
+
}): FrictionKind | undefined;
|
|
47
|
+
/** The line for a friction kind. */
|
|
48
|
+
export declare function inviteFor(kind: FrictionKind): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one line that tells an agent it can push back — worded for what just happened.
|
|
3
|
+
*
|
|
4
|
+
* Feedback is the highest-yield channel this product has: **14 reports over two days produced
|
|
5
|
+
* roughly 11 of the fixes in 2.6.0**, against ~6,100 numeric events that produced a funnel which
|
|
6
|
+
* turned out to be broken. Prose from an agent at the moment of friction beats any structured field
|
|
7
|
+
* we could add.
|
|
8
|
+
*
|
|
9
|
+
* It reached those 14 through a standing instruction in the MCP handshake — paid once at connect.
|
|
10
|
+
* The gap that leaves is the agent who hits something mid-task and is concentrating on the problem
|
|
11
|
+
* rather than on remembering that a feedback tool exists.
|
|
12
|
+
*
|
|
13
|
+
* So: invite at the moment of friction, and vary the wording. A fixed string repeated forty times
|
|
14
|
+
* is invisible by call five; a line that names what just went wrong is read because it is about the
|
|
15
|
+
* thing the agent is already looking at. ~12 tokens against a ~4,546 tok/turn surface.
|
|
16
|
+
*
|
|
17
|
+
* Every invite is counted (`feedbackPrompted`). If `feedback_submitted ÷ feedbackPrompted` stays
|
|
18
|
+
* flat, this is decoration and should be deleted — instrumenting our own nudge is the difference
|
|
19
|
+
* between a designed system and a hope.
|
|
20
|
+
*/
|
|
21
|
+
/** What just happened, as far as the chokepoint can tell. Ordered most specific first. */
|
|
22
|
+
export const FrictionKind = {
|
|
23
|
+
/** A tool that does not exist — the agent named a capability it expected us to have. */
|
|
24
|
+
UNKNOWN_TOOL: 'unknown_tool',
|
|
25
|
+
/** A verdict came back `unknown`: we drove the app and could not tell what happened. */
|
|
26
|
+
UNKNOWN_VERDICT: 'unknown_verdict',
|
|
27
|
+
/** The same tool, several times in a row. */
|
|
28
|
+
REPEATING: 'repeating',
|
|
29
|
+
/** The tool refused or errored. */
|
|
30
|
+
REFUSED: 'refused',
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Runs of one tool before it counts as being stuck.
|
|
34
|
+
*
|
|
35
|
+
* Three, matching the verdict nudge: `reticle_act` led the field data at 110 consecutive-repeat
|
|
36
|
+
* runs, and a run of three is where a deliberate retry becomes a loop.
|
|
37
|
+
*/
|
|
38
|
+
const REPEAT_INVITE_AT = 3;
|
|
39
|
+
const LINES = {
|
|
40
|
+
[FrictionKind.UNKNOWN_TOOL]: 'no such tool — reticle_feedback tells us what you expected it to do, which is how it gets built',
|
|
41
|
+
[FrictionKind.UNKNOWN_VERDICT]: 'Reticle could not tell what happened here. If you expected otherwise, reticle_feedback — an ' +
|
|
42
|
+
'unknown verdict is our defect, not yours',
|
|
43
|
+
[FrictionKind.REPEATING]: 'stuck on the same call? reticle_feedback — say what you were trying to do and it gets fixed',
|
|
44
|
+
[FrictionKind.REFUSED]: 'if that diagnosis was wrong or unhelpful, reticle_feedback — error messages are a product ' +
|
|
45
|
+
'surface and we fix the ones that fail',
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Which friction, if any, this result represents.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately reads only what the chokepoint already has: no handler changes, and nothing here can
|
|
51
|
+
* see app data.
|
|
52
|
+
*/
|
|
53
|
+
export function frictionOf(facts) {
|
|
54
|
+
if (facts.unknownTool)
|
|
55
|
+
return FrictionKind.UNKNOWN_TOOL;
|
|
56
|
+
if (facts.verifiedUnknown)
|
|
57
|
+
return FrictionKind.UNKNOWN_VERDICT;
|
|
58
|
+
// Repetition is only friction when it is repetition WITHOUT PROGRESS. Found by driving the Tauri
|
|
59
|
+
// smoke app: three consecutive SUCCESSFUL act_and_wait calls, each producing a verdict, were
|
|
60
|
+
// answered with "stuck on the same call?" — nagging the exact loop this release exists to
|
|
61
|
+
// encourage. A verdict is progress.
|
|
62
|
+
if (facts.repeatRun >= REPEAT_INVITE_AT && !facts.producedVerdict)
|
|
63
|
+
return FrictionKind.REPEATING;
|
|
64
|
+
if (facts.errored)
|
|
65
|
+
return FrictionKind.REFUSED;
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/** The line for a friction kind. */
|
|
69
|
+
export function inviteFor(kind) {
|
|
70
|
+
return LINES[kind];
|
|
71
|
+
}
|