@reticlehq/server 2.5.0 → 2.6.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 +27 -10
- package/dist/bridge/bridge.d.ts +12 -0
- package/dist/bridge/bridge.js +32 -2
- package/dist/bridge/bridge.test-harness.d.ts +8 -0
- package/dist/bridge/bridge.test-harness.js +9 -1
- package/dist/cli/cli-doctor.d.ts +0 -5
- package/dist/cli/cli-doctor.js +71 -3
- 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/doctor-daemon-line.d.ts +37 -0
- package/dist/cli/doctor-daemon-line.js +35 -0
- package/dist/cli/port-holder.d.ts +43 -0
- package/dist/cli/port-holder.js +99 -0
- package/dist/cli.js +131 -11
- 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/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 +10 -0
- package/dist/events/predicate-eval.js +24 -4
- package/dist/events/predicate-parse.d.ts +23 -0
- package/dist/events/predicate-parse.js +54 -0
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +17 -3
- package/dist/events/ring-buffer.js +1 -16
- 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/verified.d.ts +10 -1
- package/dist/honesty/verified.js +16 -0
- package/dist/http-server.js +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +10 -1
- package/dist/init/agent-rules.d.ts +18 -0
- package/dist/init/agent-rules.js +22 -4
- package/dist/init/cra.d.ts +13 -0
- package/dist/init/cra.js +16 -0
- package/dist/init/detect.d.ts +6 -0
- package/dist/init/detect.js +8 -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/plan-framework.js +36 -2
- package/dist/init/plan.d.ts +14 -0
- package/dist/init/plan.js +73 -9
- package/dist/init/run.d.ts +18 -1
- package/dist/init/run.js +133 -16
- 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 +30 -7
- package/dist/mcp/mcp.js +29 -2
- package/dist/pool/browser-pool.d.ts +9 -0
- package/dist/pool/browser-pool.js +38 -9
- package/dist/session/attachment-history.d.ts +46 -0
- package/dist/session/attachment-history.js +65 -0
- package/dist/session/no-session-diagnosis.d.ts +7 -0
- package/dist/session/no-session-diagnosis.js +53 -3
- 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/session-info.d.ts +17 -1
- package/dist/session/session-info.js +5 -1
- package/dist/session/session-manager.d.ts +3 -0
- package/dist/session/session-manager.js +72 -3
- package/dist/session/session.d.ts +1 -0
- package/dist/session/session.js +8 -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 +28 -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 +19 -1
- package/dist/telemetry/telemetry.js +31 -8
- package/dist/tools/act-tools.js +33 -6
- package/dist/tools/assert-verdict.d.ts +7 -1
- package/dist/tools/assert-verdict.js +15 -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 +83 -7
- 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 +69 -1
- 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 +8 -0
- package/dist/tools/tool-surface.js +13 -0
- package/docs/README.md +55 -0
- package/docs/agent-cheatsheet.md +2 -2
- 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 +2 -2
- package/docs/telemetry.md +7 -0
- package/package.json +2 -2
|
@@ -18,7 +18,7 @@ import { spawn } from 'node:child_process';
|
|
|
18
18
|
import { randomUUID, createHash } from 'node:crypto';
|
|
19
19
|
import { appendFileSync, readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, } from 'node:fs';
|
|
20
20
|
import { homedir, platform } from 'node:os';
|
|
21
|
-
import { join } from 'node:path';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
22
|
import { TelemetryEventKind, TELEMETRY_EVENT_VERSION, ProjectIdSource, isSessionScoped, } from '@reticlehq/core';
|
|
23
23
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
24
24
|
const RETICLE_DIR = join(homedir(), '.reticle');
|
|
@@ -144,17 +144,40 @@ const resolveIdentity = () => {
|
|
|
144
144
|
* credentials, `.git` suffix and case removed) so `git@github.com:acme/web.git` and
|
|
145
145
|
* `https://github.com/Acme/web` are recognized as the same project rather than two.
|
|
146
146
|
*/
|
|
147
|
-
const projectFingerprint = (cwd) => {
|
|
148
|
-
const { origin } = gitFacts(cwd);
|
|
147
|
+
export const projectFingerprint = (cwd) => {
|
|
148
|
+
const { origin, root } = gitFacts(cwd);
|
|
149
|
+
// Ordered by stability, not by preference: an origin is comparable across machines, a repo root is
|
|
150
|
+
// stable across every directory inside one machine's checkout, a package.json root is the same for
|
|
151
|
+
// a non-git project — and the raw cwd is stable across nothing, which is why it is last. See
|
|
152
|
+
// `project-identity.test.ts` for what a wrong order costs.
|
|
153
|
+
const { key, source } = origin !== undefined
|
|
154
|
+
? { key: origin, source: ProjectIdSource.GIT_ORIGIN }
|
|
155
|
+
: root !== undefined
|
|
156
|
+
? { key: root, source: ProjectIdSource.GIT_ROOT }
|
|
157
|
+
: packageRoot(cwd) !== null
|
|
158
|
+
? { key: packageRoot(cwd) ?? cwd, source: ProjectIdSource.PACKAGE_ROOT }
|
|
159
|
+
: { key: cwd, source: ProjectIdSource.CWD };
|
|
149
160
|
return {
|
|
150
|
-
projectId: createHash('sha256')
|
|
151
|
-
.update(origin ?? cwd)
|
|
152
|
-
.digest('hex')
|
|
153
|
-
.slice(0, 32),
|
|
161
|
+
projectId: createHash('sha256').update(key).digest('hex').slice(0, 32),
|
|
154
162
|
// Reported alongside so the analytics knows whether this id is comparable to another machine's.
|
|
155
|
-
source
|
|
163
|
+
source,
|
|
156
164
|
};
|
|
157
165
|
};
|
|
166
|
+
/** Nearest ancestor holding a package.json — the project boundary when there is no git. */
|
|
167
|
+
const packageRoot = (cwd) => {
|
|
168
|
+
let dir = cwd;
|
|
169
|
+
// Same bound as the git walk: deep monorepos are well inside 20, and an unbounded loop on a
|
|
170
|
+
// pathological path would hang the CLI's startup.
|
|
171
|
+
for (let depth = 0; depth < 20; depth += 1) {
|
|
172
|
+
if (existsSync(join(dir, 'package.json')))
|
|
173
|
+
return dir;
|
|
174
|
+
const parent = dirname(dir);
|
|
175
|
+
if (parent === dir)
|
|
176
|
+
return null;
|
|
177
|
+
dir = parent;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
};
|
|
158
181
|
/** Print the opt-out notice exactly once per machine (honest disclosure, the OSS-trust bar). */
|
|
159
182
|
const showNoticeOnce = () => {
|
|
160
183
|
try {
|
package/dist/tools/act-tools.js
CHANGED
|
@@ -11,6 +11,7 @@ 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`): 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, { 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()))
|
|
@@ -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,7 +348,7 @@ 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
|
|
@@ -404,12 +420,19 @@ export const ACT_TOOLS = [
|
|
|
404
420
|
// structural boundary (virtualized rows, a cross-origin frame) is reported as coverage and
|
|
405
421
|
// must not downgrade a verdict about what WAS observed.
|
|
406
422
|
const impeaching = buildCoverageStatement(spots.filter((s) => impeachesCapture(s.kind)));
|
|
423
|
+
// Same rule as reticle_assert: a browser-side transport gap means part of this window was
|
|
424
|
+
// never seen, which is what `blindSpots` exists to say. `truncated` above covers the SERVER
|
|
425
|
+
// ring buffer evicting; this covers the BROWSER queue overflowing, and they are not the same
|
|
426
|
+
// loss — an act_and_wait that graded `proved` over 34 dropped events said so in a sentence
|
|
427
|
+
// whose own words were "over a clean capture".
|
|
428
|
+
const gapNote = transportGapNote(windowEvents);
|
|
429
|
+
const impeachingNotes = [impeaching.note, gapNote].filter((n) => n !== undefined);
|
|
407
430
|
const honesty = buildHonestyBlock({
|
|
408
431
|
grade: gradeOf(gradedLinks),
|
|
409
432
|
attribution: 'window',
|
|
410
433
|
truncated: session.bufferHealth().dropped > droppedBefore,
|
|
411
434
|
coveragePartial: Coverage.PARTIAL === coverage.coverage,
|
|
412
|
-
...(
|
|
435
|
+
...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
|
|
413
436
|
});
|
|
414
437
|
const capsuleSaved = await saveFailedAssertCapsule({
|
|
415
438
|
deps,
|
|
@@ -436,6 +459,10 @@ export const ACT_TOOLS = [
|
|
|
436
459
|
...(alreadyTrue ? { alreadyTrue } : {}),
|
|
437
460
|
// An assertion nobody could evaluate must not be reported as one the app failed.
|
|
438
461
|
...(verdict.inconclusive === undefined ? {} : { inconclusive: verdict.inconclusive }),
|
|
462
|
+
// Nor must one nobody could OBSERVE. This is the act path, so it is the one that produced
|
|
463
|
+
// the measured false red: a reload mid-wait, graded assertion_failed at the clicked
|
|
464
|
+
// component's own file and line.
|
|
465
|
+
...(true === verdict.observationLost ? { observationLost: true } : {}),
|
|
439
466
|
honesty,
|
|
440
467
|
contradictions,
|
|
441
468
|
...(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,5 @@
|
|
|
1
1
|
import { findContradictions } from '../events/contradictions.js';
|
|
2
|
-
import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
|
|
2
|
+
import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, transportGapNote, } from '../honesty/blind-spots.js';
|
|
3
3
|
import { buildHonestyBlock } from '../honesty/honesty.js';
|
|
4
4
|
import { hasAcceptedWrite } from '../honesty/accepted-write.js';
|
|
5
5
|
import { hasUnreadWriteOutcome } from '../honesty/unread-outcome.js';
|
|
@@ -20,7 +20,13 @@ import { gradeOfPredicate } from './assert-grade.js';
|
|
|
20
20
|
*/
|
|
21
21
|
export async function assertVerdict(session, predicate, pass, since,
|
|
22
22
|
/** Set when the assertion was never evaluated — see honesty/verified.ts. */
|
|
23
|
-
inconclusive
|
|
23
|
+
inconclusive,
|
|
24
|
+
/**
|
|
25
|
+
* Set when the tab went away mid-wait, so the assertion was never OBSERVED. `reticle_assert` and
|
|
26
|
+
* `reticle_wait_for` reach the same disconnect path as the act tools, and a verdict that blames
|
|
27
|
+
* the app for a lost connection is no more honest on this route than on that one.
|
|
28
|
+
*/
|
|
29
|
+
observationLost) {
|
|
24
30
|
// Scope caveat, stated because it is a real limitation and not a bug: blind spots are tracked per
|
|
25
31
|
// SESSION, not per assertion window, so a cross-origin iframe seen once marks every later verdict
|
|
26
32
|
// in that session partial — including ones about a region it cannot affect. That errs toward
|
|
@@ -46,16 +52,22 @@ inconclusive) {
|
|
|
46
52
|
// Only a spot that IMPEACHES the capture downgrades a verdict. A structural boundary (virtualized
|
|
47
53
|
// rows, a cross-origin frame) is reported as coverage and must not impugn what WAS observed.
|
|
48
54
|
const impeaching = buildCoverageStatement(spots.filter((sp) => impeachesCapture(sp.kind)));
|
|
55
|
+
// A gap in the WINDOW, as opposed to a standing limit of the page. Both mean the same thing to the
|
|
56
|
+
// rule — part of what happened was not seen — so both belong in `blindSpots`, which is the only
|
|
57
|
+
// input `decideVerified` reads for that.
|
|
58
|
+
const gap = transportGapNote(windowEvents);
|
|
59
|
+
const impeachingNotes = [impeaching.note, gap].filter((n) => n !== undefined);
|
|
49
60
|
const outcomePending = hasAcceptedWrite(windowEvents);
|
|
50
61
|
const outcomeUnread = hasUnreadWriteOutcome(windowEvents);
|
|
51
62
|
const decision = decideVerified({
|
|
52
63
|
pass,
|
|
53
64
|
...(inconclusive === undefined ? {} : { inconclusive }),
|
|
65
|
+
...(true === observationLost ? { observationLost: true } : {}),
|
|
54
66
|
honesty: buildHonestyBlock({
|
|
55
67
|
grade: gradeOfPredicate(predicate),
|
|
56
68
|
attribution: 'window',
|
|
57
69
|
coveragePartial: Coverage.PARTIAL === statement.coverage,
|
|
58
|
-
...(
|
|
70
|
+
...(0 === impeachingNotes.length ? {} : { blindSpots: impeachingNotes }),
|
|
59
71
|
}),
|
|
60
72
|
contradictions,
|
|
61
73
|
...(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
|
|
@@ -40,7 +40,13 @@ function capMessage(message) {
|
|
|
40
40
|
* it was still collecting the defect ask, inviting a bug report about a refusal that had just told
|
|
41
41
|
* the agent precisely what to do.
|
|
42
42
|
*/
|
|
43
|
-
const SELF_RECOVERING = [
|
|
43
|
+
const SELF_RECOVERING = [
|
|
44
|
+
/cannot drive native input/i,
|
|
45
|
+
// The unknown-session refusal now lists the live ids inline. Appending "call reticle_sessions for
|
|
46
|
+
// the current ids" to a message that just named them contradicts the shorter path it offers, and
|
|
47
|
+
// a contradicted instruction is how one agent came to retry a dead id twelve times.
|
|
48
|
+
/Connected right now:/i,
|
|
49
|
+
];
|
|
44
50
|
/** A message that already carries its own concrete next action, so nothing should be appended. */
|
|
45
51
|
function isSelfRecovering(message) {
|
|
46
52
|
if (message.includes(SELF_RECOVERING_MARKER))
|
|
@@ -167,8 +173,13 @@ const RULES = [
|
|
|
167
173
|
match: /potentially destructive (?:\w+ )*(?:action|tool) blocked/i,
|
|
168
174
|
hint: RECOVERY.CONFIRM_DANGEROUS,
|
|
169
175
|
},
|
|
176
|
+
// Split in two because `^` binds to the FIRST alternative only. As one pattern this read as though
|
|
177
|
+
// the anchor governed all four spellings; it never did, and the next person to add a fifth would
|
|
178
|
+
// have had a coin-flip's chance of getting the behaviour they intended. The anchored spelling is
|
|
179
|
+
// one Reticle authors at the start of its own message; the other three appear mid-message.
|
|
180
|
+
{ match: /^cannot [\w()]+ (?:a|an|into a|on a) </i, hint: RECOVERY.WRONG_TARGET },
|
|
170
181
|
{
|
|
171
|
-
match:
|
|
182
|
+
match: /no form to submit|upload target must be|is not an HTMLElement/i,
|
|
172
183
|
hint: RECOVERY.WRONG_TARGET,
|
|
173
184
|
},
|
|
174
185
|
// Authored by Reticle, about the caller's arguments: the message already names the valid answers.
|
|
@@ -205,8 +216,74 @@ export function recoveryFor(message) {
|
|
|
205
216
|
*
|
|
206
217
|
* Matched by the SHAPES the validators actually produce (zod's codes, and the MCP layer's wrapper),
|
|
207
218
|
* because these are our own machinery talking, not the app.
|
|
219
|
+
*
|
|
220
|
+
* `did not parse` is the shape `parsePredicate` produces. It was added because removing the raw zod
|
|
221
|
+
* array from that path ALSO removed the codes this pattern keys on — so a malformed predicate
|
|
222
|
+
* stopped being recognised as the agent's mistake and started being blamed on Reticle, complete with
|
|
223
|
+
* a request for a bug report. The e2e battery caught it (`no bad argument is blamed on Reticle`);
|
|
224
|
+
* no unit test could, which is why one now exists below.
|
|
225
|
+
*/
|
|
226
|
+
/** Max issues rendered — a union rejection can emit one per member, which is how the array became unreadable. */
|
|
227
|
+
const MAX_RENDERED_ISSUES = 3;
|
|
228
|
+
function isZodIssue(value) {
|
|
229
|
+
if (typeof value !== 'object' || null === value)
|
|
230
|
+
return false;
|
|
231
|
+
const v = value;
|
|
232
|
+
return 'string' === typeof v['code'] && 'string' === typeof v['message'];
|
|
233
|
+
}
|
|
234
|
+
/** `["net","urlContains"]` → `net.urlContains`; an empty path means the object itself. */
|
|
235
|
+
function issueClause(issue) {
|
|
236
|
+
if ('unrecognized_keys' === issue.code && Array.isArray(issue.keys)) {
|
|
237
|
+
return `unknown field ${issue.keys.map(String).join(', ')}`;
|
|
238
|
+
}
|
|
239
|
+
const path = issue.path ?? [];
|
|
240
|
+
return `${0 === path.length ? 'the arguments' : path.map(String).join('.')}: ${issue.message}`;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* A serialized zod issue array, rendered as a sentence. Anything else is returned untouched.
|
|
244
|
+
*
|
|
245
|
+
* This is the one boundary every agent-visible tool failure crosses, which is why the rewrite lives
|
|
246
|
+
* here rather than in each tool. `parsePredicate` solved this for the three predicate tools by never
|
|
247
|
+
* producing the array; it cannot help anywhere else, because the MCP SDK validates a tool's schema
|
|
248
|
+
* BEFORE our handler runs. So `reticle_session` with no action — the commonest mistake there is —
|
|
249
|
+
* never reaches the friendly "unknown action … expected: [tune, yield, …]" its handler already
|
|
250
|
+
* builds, and the agent gets the raw array instead. Driven live against the shipped daemon.
|
|
251
|
+
*
|
|
252
|
+
* Conservative by construction: it only rewrites a JSON ARRAY whose every element carries both
|
|
253
|
+
* `code` and `message`. A tool that legitimately returns JSON is left alone.
|
|
254
|
+
*/
|
|
255
|
+
export function zodArrayAsSentence(message) {
|
|
256
|
+
const text = message.trim();
|
|
257
|
+
if (!text.startsWith('['))
|
|
258
|
+
return message;
|
|
259
|
+
let parsed;
|
|
260
|
+
try {
|
|
261
|
+
parsed = JSON.parse(text);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return message; // not JSON at all — e.g. prose that happens to open with a bracket
|
|
265
|
+
}
|
|
266
|
+
if (!Array.isArray(parsed) || 0 === parsed.length || !parsed.every(isZodIssue))
|
|
267
|
+
return message;
|
|
268
|
+
const shown = parsed.slice(0, MAX_RENDERED_ISSUES).map(issueClause).join('; ');
|
|
269
|
+
const rest = parsed.length - MAX_RENDERED_ISSUES;
|
|
270
|
+
return rest > 0 ? `${shown} (and ${String(rest)} more)` : shown;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* `unknown field` and `<path>: Required` are the shapes `zodArrayAsSentence` produces.
|
|
274
|
+
*
|
|
275
|
+
* Added for the same reason `did not parse` was: rendering the array as a sentence REMOVES the very
|
|
276
|
+
* codes (`invalid_type`, `unrecognized_keys`) this pattern matched on, so a schema rejection fell
|
|
277
|
+
* through to the generic branch and came back as "may be a defect in Reticle" — worse than the dump
|
|
278
|
+
* it replaced, because it also asks for a bug report about the agent's own malformed call. This repo
|
|
279
|
+
* has now made that mistake twice; the second time a test caught it before CI did.
|
|
280
|
+
*
|
|
281
|
+
* Widened a THIRD time for PR #182, which formats the MCP SDK's validation error at the argument
|
|
282
|
+
* boundary — "Missing required parameter for reticle_session: action (one of: …)". That is better
|
|
283
|
+
* prose than the field-level sentence below (it names the TOOL and the allowed VALUES) and it was
|
|
284
|
+
* authored before this formatter existed, so it must not be punished for arriving first.
|
|
208
285
|
*/
|
|
209
|
-
const ARGUMENT_REJECTION = /Unrecognized key|unrecognized_keys|invalid_type|Invalid arguments for tool|Unknown parameters? for|^Required |Expected \w+, received/i;
|
|
286
|
+
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
287
|
/**
|
|
211
288
|
* What to say instead of asking for a bug report. The schema already stated the problem precisely;
|
|
212
289
|
* this only says who can fix it and where to look.
|
|
@@ -237,10 +314,9 @@ export const FEEDBACK_ASK = 'This error is not one Reticle recognizes, which mea
|
|
|
237
314
|
* agent always gets exactly one next move.
|
|
238
315
|
*/
|
|
239
316
|
export function buildErrorPayload(rawMessage) {
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
const message = capMessage(rawMessage);
|
|
317
|
+
// Render the zod array BEFORE capping: the cap would otherwise truncate the JSON mid-issue and
|
|
318
|
+
// leave an unparseable fragment as the agent's error text — the worst of both shapes.
|
|
319
|
+
const message = capMessage(zodArrayAsSentence(rawMessage));
|
|
244
320
|
// A self-diagnosing message gets NEITHER a generic recovery nor the feedback ask. It already
|
|
245
321
|
// inspected the machine and named the cause; a second, contradictory hint is noise, and inviting a
|
|
246
322
|
// 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
|
+
}
|
|
@@ -7,10 +7,4 @@ export declare const SESSION_BOUND_TOOLS: ReadonlySet<string>;
|
|
|
7
7
|
* `sessionId`-bearing tool to be classified into exactly one set (bound XOR exempt).
|
|
8
8
|
*/
|
|
9
9
|
export declare const SESSION_EXEMPT_TOOLS: ReadonlySet<string>;
|
|
10
|
-
/**
|
|
11
|
-
* The single entry point both the MCP server and the programmatic invoker call instead of
|
|
12
|
-
* `tool.handler` directly. Runs the handler, then — for a live-session tool returning a plain
|
|
13
|
-
* object that did not already include `session` — splices the health envelope on. Idempotent
|
|
14
|
-
* (handlers that already add health are left untouched) and never alters non-object results.
|
|
15
|
-
*/
|
|
16
10
|
export declare function runTool(tool: ToolDef, deps: ToolDeps, args: Record<string, unknown>): Promise<unknown>;
|