e2e 0.21.1 → 0.21.2
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/README.md +2 -0
- package/dist/.build.json +1 -1
- package/dist/agent/act-validation.js +1 -0
- package/dist/agent/act.js +7 -3
- package/dist/agent/action-dispatcher.js +30 -8
- package/dist/agent/actions.d.ts +2 -0
- package/dist/agent/actions.js +5 -2
- package/dist/agent/error.js +1 -0
- package/dist/agent/executor.d.ts +14 -6
- package/dist/agent/index.js +5 -1
- package/dist/agent/invocation.d.ts +3 -0
- package/dist/agent/model/sdk.d.ts +5 -0
- package/dist/agent/model/sdk.js +18 -1
- package/dist/agent/observation.js +1 -0
- package/dist/agent/primitives.js +14 -3
- package/dist/agent/replay.js +9 -3
- package/dist/agent/step-cache.d.ts +3 -2
- package/dist/agent/step-cache.js +7 -4
- package/dist/agent/tool-loop.js +4 -1
- package/dist/cache/context.js +1 -1
- package/dist/cache/recorder.d.ts +2 -1
- package/dist/cache/recorder.js +11 -2
- package/dist/cache/trace-fields.d.ts +10 -0
- package/dist/cache/trace-fields.js +17 -0
- package/dist/cache/trace.d.ts +9 -1
- package/dist/cache/trace.js +26 -7
- package/dist/cli/feedback.js +2 -1
- package/dist/cli/init/sibling-versions.json +1 -1
- package/dist/collect/select.js +12 -2
- package/dist/config/agent.d.ts +1 -1
- package/dist/config/diagnose.js +9 -3
- package/dist/config/esm-hooks.js +3 -2
- package/dist/config/resolve.js +3 -2
- package/dist/config/typescript.js +52 -2
- package/dist/engine/contract.d.ts +3 -2
- package/dist/engine/manifest.js +2 -2
- package/dist/internal/errors.js +5 -2
- package/dist/internal/source.js +2 -0
- package/dist/internal/unserved-model.d.ts +5 -0
- package/dist/internal/unserved-model.js +22 -0
- package/dist/locator/screen.js +1 -2
- package/dist/oauth/copilot.js +23 -5
- package/dist/oauth/device-code.js +35 -19
- package/dist/oauth/errors.d.ts +3 -0
- package/dist/oauth/errors.js +26 -11
- package/dist/oauth/providers/github-copilot.d.ts +4 -0
- package/dist/oauth/providers/github-copilot.js +64 -16
- package/dist/oauth/providers/openai.d.ts +2 -2
- package/dist/oauth/providers/openai.js +47 -19
- package/dist/oauth/providers/opencode-console.js +36 -15
- package/dist/oauth/providers/xai.js +18 -9
- package/dist/oauth/token-endpoint.d.ts +15 -6
- package/dist/oauth/token-endpoint.js +18 -6
- package/dist/report/code-frame.js +9 -3
- package/dist/report/failure-text.d.ts +10 -2
- package/dist/report/failure-text.js +24 -8
- package/dist/report/list-model.d.ts +13 -0
- package/dist/report/list-steps.d.ts +6 -1
- package/dist/report/list-steps.js +29 -0
- package/dist/report/list.d.ts +1 -0
- package/dist/report/list.js +16 -7
- package/dist/report/live-window.js +2 -1
- package/dist/report/markdown.js +1 -1
- package/dist/run/fixtures.js +2 -0
- package/dist/run/host-trace.d.ts +3 -2
- package/dist/run/host-trace.js +2 -1
- package/dist/run/last-run.d.ts +9 -0
- package/dist/run/last-run.js +28 -0
- package/dist/run/open-session.d.ts +3 -1
- package/dist/run/open-session.js +1 -1
- package/dist/run/runner.js +6 -2
- package/dist/run/sessions.js +3 -3
- package/dist/run/standalone-attempt.d.ts +2 -1
- package/dist/run/standalone-attempt.js +3 -1
- package/dist/run/steps.d.ts +3 -1
- package/dist/run/units.d.ts +8 -0
- package/dist/run/units.js +14 -0
- package/dist/run/worker-models.d.ts +2 -0
- package/dist/run/worker-models.js +7 -0
- package/dist/types.d.ts +2 -2
- package/docs/authentication.mdx +26 -23
- package/docs/browser.mdx +7 -4
- package/docs/bug-bash.mdx +2 -2
- package/docs/cache.mdx +6 -1
- package/docs/ci/bitrise.mdx +1 -1
- package/docs/ci/codemagic.mdx +2 -2
- package/docs/ci/eas-workflows.mdx +1 -1
- package/docs/coding-agents.mdx +7 -35
- package/docs/debugging.mdx +24 -36
- package/docs/executors.mdx +1 -1
- package/docs/github.mdx +2 -1
- package/docs/integrations/eas.mdx +1 -1
- package/docs/integrations/index.mdx +6 -0
- package/docs/integrations/lightpanda.mdx +192 -0
- package/docs/integrations/testmu.mdx +152 -0
- package/docs/mcp.mdx +123 -0
- package/docs/migrate/detox.mdx +1 -1
- package/docs/migrate/maestro.mdx +101 -105
- package/docs/migrate/playwright.mdx +65 -70
- package/docs/mobile.mdx +18 -3
- package/docs/models.mdx +4 -2
- package/docs/quickstart.mdx +7 -0
- package/docs/reference/agent.mdx +369 -277
- package/docs/reference/app.mdx +141 -32
- package/docs/reference/cli.mdx +674 -558
- package/docs/reference/config.mdx +1406 -752
- package/docs/reference/engine.mdx +1062 -256
- package/docs/reference/environment.mdx +156 -84
- package/docs/reference/errors.mdx +106 -72
- package/docs/reference/expect.mdx +752 -372
- package/docs/reference/mcp.mdx +366 -141
- package/docs/reference/mobile.mdx +844 -403
- package/docs/reference/reporters.mdx +312 -106
- package/docs/reference/runner.mdx +373 -174
- package/docs/reference/screen.mdx +931 -398
- package/docs/reference/security.mdx +106 -40
- package/docs/reference/test.mdx +626 -273
- package/docs/reference/web.mdx +956 -533
- package/docs/security.mdx +6 -5
- package/docs/snippets/model-provider.jsx +763 -0
- package/docs/snippets/setup-prompt-card.jsx +22 -0
- package/docs/snippets/show-more.jsx +22 -0
- package/docs/snippets/video-card.jsx +34 -0
- package/docs/snippets/video.jsx +25 -0
- package/docs/subscriptions.mdx +10 -3
- package/docs/telemetry.mdx +39 -39
- package/package.json +3 -2
- package/schema/report-v1.schema.json +6 -0
- package/skills/e2e/references/agent.md +8 -3
- package/skills/e2e/references/debugging.md +1 -1
- package/skills/e2e/references/mcp.md +1 -1
- package/skills/e2e/references/setup.md +12 -3
- package/skills/e2e/references/writing-tests.md +1 -1
package/README.md
CHANGED
|
@@ -58,10 +58,12 @@ and Flutter, each a standalone project with a passing suite.
|
|
|
58
58
|
| [`@e2e-dev/mobile`](https://www.npmjs.com/package/@e2e-dev/mobile) | iOS and Android engine: simulators and emulators through agent-device. |
|
|
59
59
|
| [`@e2e-dev/github`](https://www.npmjs.com/package/@e2e-dev/github) | Reporter that posts results as a pull request comment. |
|
|
60
60
|
| [`@e2e-dev/kernel`](https://www.npmjs.com/package/@e2e-dev/kernel) | Kernel hosted browsers for the web engine. |
|
|
61
|
+
| [`@e2e-dev/lightpanda`](https://www.npmjs.com/package/@e2e-dev/lightpanda) | Lightpanda, the open-source headless browser, started per run for the web engine. |
|
|
61
62
|
| [`@e2e-dev/limrun`](https://www.npmjs.com/package/@e2e-dev/limrun) | Limrun hosted iOS simulators and Android emulators for mobile tests. |
|
|
62
63
|
| [`@e2e-dev/eas`](https://www.npmjs.com/package/@e2e-dev/eas) | EAS Simulators hosted iOS simulators and Android emulators for the mobile engine. |
|
|
63
64
|
| [`@e2e-dev/smol`](https://www.npmjs.com/package/@e2e-dev/smol) | smol machines browsers for the web engine: by default, each attempt branches a warm Chromium microVM. |
|
|
64
65
|
| [`@e2e-dev/decision`](https://e2e.tester.army/docs/decision-models) | Decision-model executors for bounded semantic actions and assertions. |
|
|
66
|
+
| [`@e2e-dev/testmu`](https://www.npmjs.com/package/@e2e-dev/testmu) | TestMu AI (formerly LambdaTest) hosted Android emulators, iOS simulators, and real devices for the mobile engine. |
|
|
65
67
|
|
|
66
68
|
## Documentation
|
|
67
69
|
|
package/dist/.build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.21.
|
|
1
|
+
{"version":"0.21.2"}
|
|
@@ -85,6 +85,7 @@ export function validateParams(params) {
|
|
|
85
85
|
throw new TestError('INVALID_ARGUMENT', `agent.act params are ${bytes} canonical bytes; the maximum is ${MAX_PARAMS_BYTES}`);
|
|
86
86
|
}
|
|
87
87
|
return {
|
|
88
|
+
// oxlint-disable-next-line e2e/no-unchecked-json -- canonical is this function's own JSON.stringify of a plain object validateJsonValue just checked
|
|
88
89
|
projected: JSON.parse(canonical),
|
|
89
90
|
secrets: found.secrets,
|
|
90
91
|
templates: found.templates,
|
package/dist/agent/act.js
CHANGED
|
@@ -28,7 +28,7 @@ import { resolveBoundedBudget, resolveTimeout } from './call-options.js';
|
|
|
28
28
|
import { RUNTIME_CODES } from './executor.js';
|
|
29
29
|
import { projectPriorSteps, serializeLedger } from './ledger.js';
|
|
30
30
|
import { tokenUpperBound } from './model/adapter.js';
|
|
31
|
-
import { instantiateLanguageModel } from './model/sdk.js';
|
|
31
|
+
import { instantiateLanguageModel, isUnservedModelError, unservedModelError } from './model/sdk.js';
|
|
32
32
|
import { ObservationFeed } from './observation-feed.js';
|
|
33
33
|
import { recordPolicyEvent } from './phases.js';
|
|
34
34
|
import { appLocation } from '../cache/route.js';
|
|
@@ -111,7 +111,10 @@ agent = runtime.select(raw.agent)) {
|
|
|
111
111
|
verdict = await dispatch.run();
|
|
112
112
|
}
|
|
113
113
|
catch (cause) {
|
|
114
|
-
|
|
114
|
+
const error = unservedModelError(cause) ?? toAgentError(cause);
|
|
115
|
+
if (isUnservedModelError(error))
|
|
116
|
+
runtime.reportUnservedModel(error);
|
|
117
|
+
throw dispatch.settleThrown(error);
|
|
115
118
|
}
|
|
116
119
|
const evidence = await dispatch.captureEvidence();
|
|
117
120
|
dispatch.settle(validateVerdict(verdict, agent.executor.name), evidence);
|
|
@@ -309,7 +312,8 @@ class ActDispatch {
|
|
|
309
312
|
}
|
|
310
313
|
/** Hands the settled outcome to the cache session, which stages, evicts, or does nothing. */
|
|
311
314
|
async conclude(outcome) {
|
|
312
|
-
|
|
315
|
+
const model = this.agent.config.model;
|
|
316
|
+
await this.stepCache?.conclude(outcome, this.explanation, model === undefined ? undefined : { provider: model.provider, model: model.id });
|
|
313
317
|
}
|
|
314
318
|
/**
|
|
315
319
|
* Keeps a redacted screenshot as an assert step's evidence once the
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* nothing else in the step changes. Project tools run through the same budget
|
|
10
10
|
* and queue as `runTool`.
|
|
11
11
|
*/
|
|
12
|
+
import { isKeyModifier, KEY_MODIFIERS } from '../engine/contract.js';
|
|
12
13
|
import { asEngineError, TestError } from '../internal/errors.js';
|
|
13
14
|
import { requireKey } from '../internal/keys.js';
|
|
14
15
|
import { clampToViewport, requireFinitePoint, viewportShare } from '../internal/geometry.js';
|
|
@@ -16,6 +17,7 @@ import { resolveNavigationUrl } from '../internal/urls.js';
|
|
|
16
17
|
import { PROJECT_TOOL_EVENT_PREFIX } from './action-names.js';
|
|
17
18
|
import { containerKey, describeAction } from './actions.js';
|
|
18
19
|
import { describePosition } from '../cache/relocate.js';
|
|
20
|
+
import { isTapActionName } from '../cache/trace.js';
|
|
19
21
|
import { derivedReason } from './derived.js';
|
|
20
22
|
import { AgentError } from './error.js';
|
|
21
23
|
import { resolveScrollTarget } from './scroll-target.js';
|
|
@@ -68,6 +70,23 @@ const NODE_ACTION_KINDS = {
|
|
|
68
70
|
hover: 'hover',
|
|
69
71
|
scrollTo: 'scrollIntoView',
|
|
70
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* The `modifiers` of a tap-family action, validated: distinct key modifiers,
|
|
75
|
+
* as the test API's `tap({ modifiers })` takes them. An empty list holds
|
|
76
|
+
* nothing and is dropped, so the recorded action stays clean.
|
|
77
|
+
*/
|
|
78
|
+
function requireClickModifiers(value, verb) {
|
|
79
|
+
if (!Array.isArray(value) || !value.every(isKeyModifier)) {
|
|
80
|
+
throw new TestError('INVALID_ARGUMENT', `${verb}() modifiers must be an array of ${KEY_MODIFIERS.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
const modifiers = [];
|
|
83
|
+
for (const modifier of value) {
|
|
84
|
+
if (modifiers.includes(modifier))
|
|
85
|
+
throw new TestError('INVALID_ARGUMENT', `${verb}() names a modifier twice`);
|
|
86
|
+
modifiers.push(modifier);
|
|
87
|
+
}
|
|
88
|
+
return modifiers.length === 0 ? undefined : modifiers;
|
|
89
|
+
}
|
|
71
90
|
export class ActionDispatcher {
|
|
72
91
|
runtime;
|
|
73
92
|
accounting;
|
|
@@ -90,10 +109,10 @@ export class ActionDispatcher {
|
|
|
90
109
|
this.queue = queue;
|
|
91
110
|
this.options = options;
|
|
92
111
|
this.actions = {
|
|
93
|
-
tap: (target) => this.nodeVerb('tap', target),
|
|
94
|
-
doubleTap: (target) => this.nodeVerb('doubleTap', target),
|
|
112
|
+
tap: (target, click) => this.nodeVerb('tap', target, click),
|
|
113
|
+
doubleTap: (target, click) => this.nodeVerb('doubleTap', target, click),
|
|
95
114
|
longPress: (target) => this.nodeVerb('longPress', target),
|
|
96
|
-
secondaryTap: (target) => this.nodeVerb('secondaryTap', target),
|
|
115
|
+
secondaryTap: (target, click) => this.nodeVerb('secondaryTap', target, click),
|
|
97
116
|
hover: (target) => this.nodeVerb('hover', target),
|
|
98
117
|
type: (target, value) => this.type(target, value),
|
|
99
118
|
typeSecret: (target, name) => this.typeSecret(target, name),
|
|
@@ -167,8 +186,9 @@ export class ActionDispatcher {
|
|
|
167
186
|
});
|
|
168
187
|
}
|
|
169
188
|
/** One node verb that carries nothing but its target: a tap or one of its variants, a hover, a scroll into view. */
|
|
170
|
-
nodeVerb(name, target) {
|
|
171
|
-
|
|
189
|
+
nodeVerb(name, target, options) {
|
|
190
|
+
const modifiers = options?.modifiers === undefined ? undefined : requireClickModifiers(options.modifiers, name);
|
|
191
|
+
return this.commitTargeted(NODE_ACTION_KINDS[name], target, (node) => this.performNode(name, node, modifiers));
|
|
172
192
|
}
|
|
173
193
|
/**
|
|
174
194
|
* Taps or hovers one viewport point, routed onto the tree in queue order
|
|
@@ -239,9 +259,11 @@ export class ActionDispatcher {
|
|
|
239
259
|
get verbs() {
|
|
240
260
|
return this.runtime.target.verbs;
|
|
241
261
|
}
|
|
242
|
-
async performNode(name, node) {
|
|
243
|
-
|
|
244
|
-
|
|
262
|
+
async performNode(name, node, modifiers) {
|
|
263
|
+
const kind = NODE_ACTION_KINDS[name];
|
|
264
|
+
const action = modifiers !== undefined && isTapActionName(kind) ? { kind, modifiers } : { kind };
|
|
265
|
+
await this.session.perform(node.ref, action, this.accounting.actionOperation());
|
|
266
|
+
return { name, node, ...(modifiers === undefined ? {} : { modifiers }) };
|
|
245
267
|
}
|
|
246
268
|
/**
|
|
247
269
|
* Sets a checkbox, switch, or radio to a state. The engine's `check` and
|
package/dist/agent/actions.d.ts
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
import type { ViewportPoint, ViewportSize } from '../engine/surface.ts';
|
|
12
12
|
import { type NodeRedaction, type RedactedNode } from './observation.ts';
|
|
13
13
|
import { type NodeActionName, type PointActionName, type TraceTargetDescriptor, type TracePosition } from '../cache/trace.ts';
|
|
14
|
+
import type { KeyModifier } from '../engine/contract.ts';
|
|
14
15
|
import type { ScrollDirection } from '../types.ts';
|
|
15
16
|
/** One committed grammar action, addressed by the node it actually ran against. */
|
|
16
17
|
export type RecordableAction = ({
|
|
17
18
|
readonly name: NodeActionName;
|
|
18
19
|
readonly node: RedactedNode;
|
|
20
|
+
readonly modifiers?: readonly KeyModifier[];
|
|
19
21
|
} & Placement) | ({
|
|
20
22
|
readonly name: 'type';
|
|
21
23
|
readonly node: RedactedNode;
|
package/dist/agent/actions.js
CHANGED
|
@@ -119,8 +119,11 @@ export function describeAction(action, redaction) {
|
|
|
119
119
|
const where = describeForSummary(target);
|
|
120
120
|
const safe = (value) => quote(redaction.redact(sanitizeText(value)));
|
|
121
121
|
const prose = (() => {
|
|
122
|
-
if (isNodeAction(action))
|
|
123
|
-
|
|
122
|
+
if (isNodeAction(action)) {
|
|
123
|
+
const said = NODE_ACTION_PROSE[action.name](where);
|
|
124
|
+
const held = 'modifiers' in action ? action.modifiers : undefined;
|
|
125
|
+
return held === undefined || held.length === 0 ? said : `${said} with ${held.join('+')} held`;
|
|
126
|
+
}
|
|
124
127
|
switch (action.name) {
|
|
125
128
|
case 'type':
|
|
126
129
|
return `type ${safe(action.value)} into ${where}`;
|
package/dist/agent/error.js
CHANGED
|
@@ -114,6 +114,7 @@ export function toAgentError(cause) {
|
|
|
114
114
|
export function isAgentError(value) {
|
|
115
115
|
return (value instanceof Error &&
|
|
116
116
|
AGENT_ERROR_MARKER in value &&
|
|
117
|
+
'code' in value &&
|
|
117
118
|
typeof value.code === 'string' &&
|
|
118
119
|
value.code in CATEGORY_BY_CODE);
|
|
119
120
|
}
|
package/dist/agent/executor.d.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { ObservationPixels, SemanticNode, ViewportPoint, ViewportSize } from '../engine/surface.ts';
|
|
20
20
|
import type { VisionDegradation, StepTurn } from '../run/steps.ts';
|
|
21
|
-
import type { AgentErrorCode, JsonValue, ModelInstance, ProviderOptions, ScrollDirection, Secret, VisionMode } from '../types.ts';
|
|
21
|
+
import type { AgentErrorCode, ClickOptions, JsonValue, ModelInstance, ProviderOptions, ScrollDirection, Secret, VisionMode } from '../types.ts';
|
|
22
22
|
import { type AgentError } from './error.ts';
|
|
23
23
|
/**
|
|
24
24
|
* One step handed to an executor. `act` plans and executes a flow; `assert`
|
|
@@ -155,13 +155,18 @@ export interface ExecutorTarget {
|
|
|
155
155
|
* fresh `observe()`.
|
|
156
156
|
*/
|
|
157
157
|
export interface ExecutorActions {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Taps one node. `options.modifiers` holds keys for the click, as a
|
|
160
|
+
* Shift-click extends a selection; needs `target.tapModifiers`,
|
|
161
|
+
* `UNSUPPORTED_CAPABILITY` otherwise.
|
|
162
|
+
*/
|
|
163
|
+
tap(target: ExecutorTarget, options?: Pick<ClickOptions, 'modifiers'>): Promise<void>;
|
|
164
|
+
/** Double-taps one node (`doubleTap` in `target.verbs`). Takes the tap's `options.modifiers`. */
|
|
165
|
+
doubleTap(target: ExecutorTarget, options?: Pick<ClickOptions, 'modifiers'>): Promise<void>;
|
|
161
166
|
/** Presses one node and holds for the engine's default duration (`longPress` in `target.verbs`). */
|
|
162
167
|
longPress(target: ExecutorTarget): Promise<void>;
|
|
163
|
-
/** A right-click on a pointer surface (`secondaryTap` in `target.verbs`). */
|
|
164
|
-
secondaryTap(target: ExecutorTarget): Promise<void>;
|
|
168
|
+
/** A right-click on a pointer surface (`secondaryTap` in `target.verbs`). Takes the tap's `options.modifiers`. */
|
|
169
|
+
secondaryTap(target: ExecutorTarget, options?: Pick<ClickOptions, 'modifiers'>): Promise<void>;
|
|
165
170
|
/** Moves the pointer over one node without pressing (`hover` in `target.verbs`). */
|
|
166
171
|
hover(target: ExecutorTarget): Promise<void>;
|
|
167
172
|
type(target: ExecutorTarget, value: string): Promise<void>;
|
|
@@ -370,11 +375,14 @@ export interface StepExecutorContext {
|
|
|
370
375
|
* The target this step runs on. Tool packs scope themselves by its platform;
|
|
371
376
|
* `verbs` is the subset of the action grammar the engine declared, so an
|
|
372
377
|
* executor offers a model exactly the vocabulary the surface can honor.
|
|
378
|
+
* `tapModifiers` is true when `tap`, `doubleTap`, and `secondaryTap` accept
|
|
379
|
+
* `modifiers`.
|
|
373
380
|
*/
|
|
374
381
|
readonly target: {
|
|
375
382
|
readonly name: string;
|
|
376
383
|
readonly platform: string;
|
|
377
384
|
readonly verbs: ReadonlySet<ExecutorVerb>;
|
|
385
|
+
readonly tapModifiers: boolean;
|
|
378
386
|
};
|
|
379
387
|
/**
|
|
380
388
|
* Present when a cached replay ran part of this step before handing it
|
package/dist/agent/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { rejectUnknownOptions } from '../internal/options.js';
|
|
|
14
14
|
import { describeIssue, requireStandardSchema } from '../internal/standard-schema.js';
|
|
15
15
|
import { sleep } from '../internal/time.js';
|
|
16
16
|
import { AgentError, isAgentError, toAgentError } from './error.js';
|
|
17
|
+
import { isUnservedModelError, unservedModelError } from './model/sdk.js';
|
|
17
18
|
import { resolveBoundedBudget, resolveTimeout } from './call-options.js';
|
|
18
19
|
import { runActStep, runAssertStep } from './act.js';
|
|
19
20
|
import { observationShape } from './observation.js';
|
|
@@ -82,7 +83,10 @@ export function createAgentFixture(runtime) {
|
|
|
82
83
|
return await body(invocation);
|
|
83
84
|
}
|
|
84
85
|
catch (cause) {
|
|
85
|
-
|
|
86
|
+
const error = unservedModelError(cause) ?? toAgentError(cause);
|
|
87
|
+
if (isUnservedModelError(error))
|
|
88
|
+
runtime.reportUnservedModel(error);
|
|
89
|
+
throw error;
|
|
86
90
|
}
|
|
87
91
|
finally {
|
|
88
92
|
invocation.finish();
|
|
@@ -19,6 +19,7 @@ import type { ArtifactSink } from '../run/fixtures.ts';
|
|
|
19
19
|
import type { SecretExposure } from '../run/secrecy.ts';
|
|
20
20
|
import type { StepRecord, StepRecorder } from '../run/steps.ts';
|
|
21
21
|
import type { VisionMode } from '../types.ts';
|
|
22
|
+
import { AgentError } from './error.ts';
|
|
22
23
|
import type { ExecutorAttempt, StepExecutor } from './executor.ts';
|
|
23
24
|
import { type ModelAdapter } from './model/adapter.ts';
|
|
24
25
|
import { type AgentObservation } from './observation.ts';
|
|
@@ -66,6 +67,8 @@ export interface AgentContext {
|
|
|
66
67
|
* the run's. An unknown name is `INVALID_ARGUMENT`.
|
|
67
68
|
*/
|
|
68
69
|
readonly select: (name: string | undefined) => AgentSelection;
|
|
70
|
+
/** Reports a model the provider refused, met by a step's call; the run stops once over it. */
|
|
71
|
+
readonly reportUnservedModel: (error: AgentError) => void;
|
|
69
72
|
readonly config: ResolvedConfig;
|
|
70
73
|
/** The target this attempt runs on. */
|
|
71
74
|
readonly target: StepExecutorContext['target'];
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { type ResolvedModel } from '../../config/agent.ts';
|
|
10
10
|
import { type SdkLanguageModel } from '../ai-sdk.ts';
|
|
11
|
+
import { AgentError } from '../error.ts';
|
|
11
12
|
import { type ModelAdapter } from './adapter.ts';
|
|
12
13
|
/**
|
|
13
14
|
* Provider transport retries; distinct from runner-owned model-call budget.
|
|
@@ -59,6 +60,10 @@ export declare function cacheTokenFields(usage: SdkUsage | undefined): {
|
|
|
59
60
|
* the key.
|
|
60
61
|
*/
|
|
61
62
|
export declare function readCost(metadata: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined): number | undefined;
|
|
63
|
+
/** A marked failure (`internal/unserved-model.ts`) as the run-stopping `MODEL_UNAVAILABLE` the fixture reports once, or that error itself; undefined for any other failure. */
|
|
64
|
+
export declare function unservedModelError(cause: unknown): AgentError | undefined;
|
|
65
|
+
/** True for an error `unservedModelError` built, in this realm or another. */
|
|
66
|
+
export declare function isUnservedModelError(value: unknown): value is AgentError;
|
|
62
67
|
/**
|
|
63
68
|
* The remedy a provider failure points at, when its shape names one: a
|
|
64
69
|
* rejected credential, or a conversation the provider kept no state for.
|
package/dist/agent/model/sdk.js
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* provider-independent.
|
|
8
8
|
*/
|
|
9
9
|
import { providerFailureMessage, withHint } from '../../internal/errors.js';
|
|
10
|
+
import { markUnservedModel, unservedModelExplanation } from '../../internal/unserved-model.js';
|
|
10
11
|
import { missingModelError } from '../../config/agent.js';
|
|
11
12
|
import { aiSdk, asSdkLanguageModel, loadAiSdk } from '../ai-sdk.js';
|
|
12
13
|
import { MODEL_REQUEST_HEADERS } from '../../internal/client-identity.js';
|
|
13
14
|
import { packageVersion } from '../../internal/package-version.js';
|
|
14
|
-
import { AgentError } from '../error.js';
|
|
15
|
+
import { AgentError, isAgentError } from '../error.js';
|
|
15
16
|
import { isContextOverflow } from './overflow.js';
|
|
16
17
|
import { providerHints } from './provider-hints.js';
|
|
17
18
|
import { withStallGuard } from './stall.js';
|
|
@@ -238,11 +239,27 @@ function translateModelError(rawCause, issue, signal) {
|
|
|
238
239
|
if (isContextOverflow(cause)) {
|
|
239
240
|
return new AgentError('CONTEXT_OVERFLOW', `the model request exceeded the context window: ${providerFailureMessage(cause)}`, { cause });
|
|
240
241
|
}
|
|
242
|
+
const unserved = unservedModelError(cause);
|
|
243
|
+
if (unserved !== undefined)
|
|
244
|
+
return unserved;
|
|
241
245
|
if (APICallError.isInstance(cause)) {
|
|
242
246
|
return new AgentError('MODEL_PROVIDER_FAILED', withHint(`model provider failed: ${providerFailureMessage(cause)}`, failureHint(cause)), { cause });
|
|
243
247
|
}
|
|
244
248
|
return new AgentError('MODEL_PROVIDER_FAILED', `model provider failed: ${providerFailureMessage(cause)}`, { cause });
|
|
245
249
|
}
|
|
250
|
+
/** A marked failure (`internal/unserved-model.ts`) as the run-stopping `MODEL_UNAVAILABLE` the fixture reports once, or that error itself; undefined for any other failure. */
|
|
251
|
+
export function unservedModelError(cause) {
|
|
252
|
+
if (isUnservedModelError(cause))
|
|
253
|
+
return cause;
|
|
254
|
+
const explanation = unservedModelExplanation(cause);
|
|
255
|
+
if (explanation === undefined)
|
|
256
|
+
return undefined;
|
|
257
|
+
return markUnservedModel(new AgentError('MODEL_UNAVAILABLE', explanation, { cause }), explanation);
|
|
258
|
+
}
|
|
259
|
+
/** True for an error `unservedModelError` built, in this realm or another. */
|
|
260
|
+
export function isUnservedModelError(value) {
|
|
261
|
+
return isAgentError(value) && unservedModelExplanation(value) !== undefined;
|
|
262
|
+
}
|
|
246
263
|
/**
|
|
247
264
|
* The remedy a provider failure points at, when its shape names one: a
|
|
248
265
|
* rejected credential, or a conversation the provider kept no state for.
|
|
@@ -145,6 +145,7 @@ export function redactNode(node, redaction) {
|
|
|
145
145
|
}
|
|
146
146
|
if (node.children !== undefined)
|
|
147
147
|
redacted['children'] = node.children.map((child) => redactNode(child, redaction));
|
|
148
|
+
// oxlint-disable-next-line e2e/no-chained-type-assertions -- mints the phantom REDACTED brand once every field passed NODE_FIELDS
|
|
148
149
|
return redacted;
|
|
149
150
|
}
|
|
150
151
|
/**
|
package/dist/agent/primitives.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* the module is safe to import from a config that never calls a model.
|
|
10
10
|
*/
|
|
11
11
|
import { z } from 'zod';
|
|
12
|
+
import { isTapActionName } from '../cache/trace.js';
|
|
13
|
+
import { KEY_MODIFIERS } from '../engine/contract.js';
|
|
12
14
|
import { codedMessage, hasCause, TestError } from '../internal/errors.js';
|
|
13
15
|
import { isRuntimeHardStop, BLOCKABLE_CODES } from './executor.js';
|
|
14
16
|
import { cacheTokenFields, readCost } from './model/sdk.js';
|
|
@@ -231,13 +233,22 @@ export function createGrammarTools(context, options = {}) {
|
|
|
231
233
|
execute: () => inOrder(() => guard(() => present('Observed.', { expectChange: false, keyboardNote: false }))),
|
|
232
234
|
}),
|
|
233
235
|
};
|
|
236
|
+
const modifiers = z
|
|
237
|
+
.array(z.enum(KEY_MODIFIERS))
|
|
238
|
+
.optional()
|
|
239
|
+
.describe('Keys held while the pointer clicks: "Shift", "Control", "Alt", "Meta", or "ControlOrMeta". A Shift-click extends a selection, a Control-click (Meta on macOS) toggles one item.');
|
|
234
240
|
for (const { verb, tool, lead, description, expectChange } of NODE_VERBS) {
|
|
235
241
|
if (!verbs.has(verb))
|
|
236
242
|
continue;
|
|
243
|
+
const holdable = context.target.tapModifiers && isTapActionName(verb);
|
|
237
244
|
tools[tool] = screenTool({
|
|
238
|
-
description,
|
|
239
|
-
inputSchema: z.object({ target }),
|
|
240
|
-
execute: (
|
|
245
|
+
description: holdable ? `${description} Pass modifiers to hold keys for the click.` : description,
|
|
246
|
+
inputSchema: holdable ? z.object({ target, modifiers }) : z.object({ target }),
|
|
247
|
+
execute: (input) => {
|
|
248
|
+
const { target: id, modifiers: held } = input;
|
|
249
|
+
const run = () => isTapActionName(verb) && held !== undefined ? context.actions[verb]({ id }, { modifiers: held }) : context.actions[verb]({ id });
|
|
250
|
+
return acting({ done: `${lead} #${id}.`, attempt: `${tool} #${id}` }, run, expectChange === undefined ? {} : { expectChange });
|
|
251
|
+
},
|
|
241
252
|
});
|
|
242
253
|
}
|
|
243
254
|
// scroll_to reaches a node the screen lists (by id, through the engine's
|
package/dist/agent/replay.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* divergence.
|
|
13
13
|
*/
|
|
14
14
|
import { isRelocatableDescriptor, MAIN_LIST_SHARE, relocateDescriptor } from '../cache/relocate.js';
|
|
15
|
-
import { isNodeAction } from '../cache/trace.js';
|
|
15
|
+
import { isNodeAction, isTapActionName } from '../cache/trace.js';
|
|
16
16
|
import { hasCause } from '../internal/errors.js';
|
|
17
17
|
import { containsPoint } from '../internal/geometry.js';
|
|
18
18
|
import { sleep } from '../internal/time.js';
|
|
@@ -23,8 +23,14 @@ const RETRY_DELAYS_MS = [100, 300, 600, 1_000, 3_000];
|
|
|
23
23
|
/** Ceiling on one poll's total wait, inside whatever the deadline allows. */
|
|
24
24
|
const RETRY_TIMEOUT_MS = 15_000;
|
|
25
25
|
function planCall(action, actions) {
|
|
26
|
-
if (isNodeAction(action))
|
|
27
|
-
|
|
26
|
+
if (isNodeAction(action)) {
|
|
27
|
+
const { name, modifiers } = action;
|
|
28
|
+
return {
|
|
29
|
+
kind: 'targeted',
|
|
30
|
+
descriptor: action.target,
|
|
31
|
+
invoke: (t) => (isTapActionName(name) && modifiers !== undefined ? actions[name](t, { modifiers }) : actions[name](t)),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
28
34
|
switch (action.name) {
|
|
29
35
|
case 'tool':
|
|
30
36
|
return { kind: 'gap', ...(action.derived === undefined ? {} : { derived: action.derived }) };
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { AgentCacheContext } from '../cache/context.ts';
|
|
15
15
|
import { type CacheAgentIdentity } from '../cache/identity.ts';
|
|
16
16
|
import { type ParamTemplate } from '../cache/template.ts';
|
|
17
|
-
import { type DerivedReason } from '../cache/trace.ts';
|
|
17
|
+
import { type DerivedReason, type RecordedModel } from '../cache/trace.ts';
|
|
18
18
|
import type { StepCacheInfo } from '../run/steps.ts';
|
|
19
19
|
import type { JsonValue } from '../types.ts';
|
|
20
20
|
import type { RecordableAction } from './actions.ts';
|
|
@@ -71,6 +71,7 @@ export declare class StepTraceSession {
|
|
|
71
71
|
private readonly options;
|
|
72
72
|
private readonly redaction;
|
|
73
73
|
private info;
|
|
74
|
+
private recordedBy;
|
|
74
75
|
private prefix;
|
|
75
76
|
private startPath;
|
|
76
77
|
private startedMs;
|
|
@@ -170,7 +171,7 @@ export declare class StepTraceSession {
|
|
|
170
171
|
* that diverged hands off again next run, where a model that answers
|
|
171
172
|
* re-records it.
|
|
172
173
|
*/
|
|
173
|
-
conclude(outcome: StepOutcome, verdictSummary: string | undefined): Promise<void>;
|
|
174
|
+
conclude(outcome: StepOutcome, verdictSummary: string | undefined, recordedBy?: RecordedModel): Promise<void>;
|
|
174
175
|
/**
|
|
175
176
|
* One store read, revalidated through the trace-1 framing whichever store
|
|
176
177
|
* produced it: a custom store's entry can never be trusted more loosely
|
package/dist/agent/step-cache.js
CHANGED
|
@@ -69,6 +69,7 @@ export class StepTraceSession {
|
|
|
69
69
|
options;
|
|
70
70
|
redaction;
|
|
71
71
|
info;
|
|
72
|
+
recordedBy;
|
|
72
73
|
prefix;
|
|
73
74
|
startPath;
|
|
74
75
|
startedMs = Date.now();
|
|
@@ -117,7 +118,7 @@ export class StepTraceSession {
|
|
|
117
118
|
}
|
|
118
119
|
/** How the cache participated, for the step's report detail. */
|
|
119
120
|
get cacheInfo() {
|
|
120
|
-
return this.info;
|
|
121
|
+
return this.info === undefined || this.recordedBy === undefined ? this.info : { ...this.info, recordedBy: this.recordedBy };
|
|
121
122
|
}
|
|
122
123
|
/** The mid-step hand-off, when a replay diverged after real progress. */
|
|
123
124
|
get replayedPrefix() {
|
|
@@ -238,7 +239,7 @@ export class StepTraceSession {
|
|
|
238
239
|
* that diverged hands off again next run, where a model that answers
|
|
239
240
|
* re-records it.
|
|
240
241
|
*/
|
|
241
|
-
async conclude(outcome, verdictSummary) {
|
|
242
|
+
async conclude(outcome, verdictSummary, recordedBy) {
|
|
242
243
|
const recorder = this.recorder;
|
|
243
244
|
if (recorder === undefined)
|
|
244
245
|
return;
|
|
@@ -263,7 +264,7 @@ export class StepTraceSession {
|
|
|
263
264
|
});
|
|
264
265
|
}
|
|
265
266
|
else {
|
|
266
|
-
const staged = await this.stage(recorder, verdictSummary);
|
|
267
|
+
const staged = await this.stage(recorder, verdictSummary, recordedBy);
|
|
267
268
|
if (staged !== 'staged' && this.readEntryHit)
|
|
268
269
|
await this.evict();
|
|
269
270
|
else if (staged === 'no-change')
|
|
@@ -326,6 +327,7 @@ export class StepTraceSession {
|
|
|
326
327
|
async replayEntry(entry) {
|
|
327
328
|
const start = await this.captureStart('replay-start');
|
|
328
329
|
const trace = entry.payload;
|
|
330
|
+
this.recordedBy = trace.recordedBy;
|
|
329
331
|
if (!this.host.traceEligible) {
|
|
330
332
|
this.info = this.missed('truncated', trace.actions.length);
|
|
331
333
|
return undefined;
|
|
@@ -506,7 +508,7 @@ export class StepTraceSession {
|
|
|
506
508
|
* does a step that changed nothing a replay could check, no node and no
|
|
507
509
|
* route: a trace without its check would replay on mechanics alone.
|
|
508
510
|
*/
|
|
509
|
-
async stage(recorder, verdictSummary) {
|
|
511
|
+
async stage(recorder, verdictSummary, recordedBy) {
|
|
510
512
|
if (!this.host.traceEligible || this.startNodes === undefined || recorder.recordedCount === 0)
|
|
511
513
|
return 'skipped';
|
|
512
514
|
const observation = await probeScreen(this.host, 'held-still');
|
|
@@ -523,6 +525,7 @@ export class StepTraceSession {
|
|
|
523
525
|
const trace = recorder.finalize({
|
|
524
526
|
executor: this.options.executor,
|
|
525
527
|
recordedFor: this.claim.step,
|
|
528
|
+
...(recordedBy === undefined ? {} : { recordedBy }),
|
|
526
529
|
summary: verdictSummary ?? 'step passed',
|
|
527
530
|
...(this.startPath === undefined ? {} : { startPath: this.startPath }),
|
|
528
531
|
...(endPath === undefined ? {} : { endPath }),
|
package/dist/agent/tool-loop.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { asSdkLanguageModel, loadAiSdk } from './ai-sdk.js';
|
|
14
14
|
import { MODEL_REQUEST_HEADERS } from '../internal/client-identity.js';
|
|
15
15
|
import { ConfigurationError, providerFailureMessage, withHint } from '../internal/errors.js';
|
|
16
|
-
import { failureHint, isAbort, TRANSPORT_RETRIES } from './model/sdk.js';
|
|
16
|
+
import { failureHint, isAbort, TRANSPORT_RETRIES, unservedModelError } from './model/sdk.js';
|
|
17
17
|
import { isContextOverflow } from './model/overflow.js';
|
|
18
18
|
import { isForcedToolCallMismatched, isForcedToolCallSkipped, isForcedToolChoiceDowngraded, isForcedToolChoiceRejected, } from './model/tool-choice.js';
|
|
19
19
|
import { withStallGuard } from './model/stall.js';
|
|
@@ -220,6 +220,9 @@ class LoopRun {
|
|
|
220
220
|
if (isContextOverflow(cause)) {
|
|
221
221
|
throw new AgentError('CONTEXT_OVERFLOW', `the model request exceeded the context window${this.overflowNote ?? ''}: ${message}`, { cause });
|
|
222
222
|
}
|
|
223
|
+
const unserved = unservedModelError(cause);
|
|
224
|
+
if (unserved !== undefined)
|
|
225
|
+
throw unserved;
|
|
223
226
|
throw new AgentError('MODEL_PROVIDER_FAILED', withHint(`the model provider failed: ${message}`, failureHint(cause)), { cause });
|
|
224
227
|
}
|
|
225
228
|
}
|
package/dist/cache/context.js
CHANGED
|
@@ -30,7 +30,7 @@ async function holdsSameFlow(store, keyHash, trace) {
|
|
|
30
30
|
}
|
|
31
31
|
/** The part of a trace that decides what a replay does, as canonical JSON. */
|
|
32
32
|
function flowOf(trace) {
|
|
33
|
-
const { summary: _summary, endWaitMs: _endWaitMs, keyedBy: _keyedBy, actions, ...flow } = trace;
|
|
33
|
+
const { summary: _summary, endWaitMs: _endWaitMs, keyedBy: _keyedBy, recordedBy: _recordedBy, actions, ...flow } = trace;
|
|
34
34
|
return canonicalJson({
|
|
35
35
|
...flow,
|
|
36
36
|
actions: actions.map((action) => {
|
package/dist/cache/recorder.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* state. A poisoned trace still documents what happened; it never replays.
|
|
17
17
|
*/
|
|
18
18
|
import { type RecordableAction } from '../agent/actions.ts';
|
|
19
|
-
import { type ActionTrace, type DerivedReason, type TraceKeyContext, type TraceProvenance, type TraceTargetDescriptor } from './trace.ts';
|
|
19
|
+
import { type ActionTrace, type DerivedReason, type RecordedModel, type TraceKeyContext, type TraceProvenance, type TraceTargetDescriptor } from './trace.ts';
|
|
20
20
|
export interface TraceRecorderOptions {
|
|
21
21
|
/** The run's secret redactor; applied to every recorded string. */
|
|
22
22
|
readonly redact: (text: string) => string;
|
|
@@ -61,6 +61,7 @@ export declare class TraceRecorder {
|
|
|
61
61
|
};
|
|
62
62
|
/** Test, target, and instruction digest the trace was recorded for. */
|
|
63
63
|
readonly recordedFor: TraceProvenance;
|
|
64
|
+
readonly recordedBy?: RecordedModel;
|
|
64
65
|
readonly summary: string;
|
|
65
66
|
readonly startPath?: string;
|
|
66
67
|
readonly endPath?: string;
|
package/dist/cache/recorder.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { describeAction } from '../agent/actions.js';
|
|
19
19
|
import { isRelocatableDescriptor } from './relocate.js';
|
|
20
20
|
import { DESCRIPTOR_FIELDS, MAX_TRACE_ACTIONS, MAX_TRACE_DESCRIPTOR_CHARS, MAX_TRACE_END_WAIT_MS, MAX_TRACE_INPUT_CHARS, MAX_TRACE_SUMMARY_CHARS, isNodeAction, } from './trace.js';
|
|
21
|
+
import { readRecordedModel } from './trace-fields.js';
|
|
21
22
|
import { bound } from '../internal/text.js';
|
|
22
23
|
/** The summary a run-time value gap records; the prose a project-tool gap would carry for a tool of that name. */
|
|
23
24
|
const DERIVED_GAP_SUMMARY = 'tool type (run-time value)';
|
|
@@ -71,6 +72,8 @@ export class TraceRecorder {
|
|
|
71
72
|
finalize(conclusion) {
|
|
72
73
|
if (this.actions.length === 0)
|
|
73
74
|
return undefined;
|
|
75
|
+
const source = conclusion.recordedBy;
|
|
76
|
+
const recordedBy = source === undefined ? undefined : readRecordedModel({ provider: this.redact(source.provider), model: this.redact(source.model) }, MAX_TRACE_DESCRIPTOR_CHARS);
|
|
74
77
|
const summary = bound(this.redact(conclusion.summary), MAX_TRACE_SUMMARY_CHARS);
|
|
75
78
|
// Locations are replay preconditions, so they follow the input rule: a
|
|
76
79
|
// path the redactor alters carried a secret (a token in a query string),
|
|
@@ -85,6 +88,7 @@ export class TraceRecorder {
|
|
|
85
88
|
...(conclusion.executor.version === undefined ? {} : { version: conclusion.executor.version }),
|
|
86
89
|
},
|
|
87
90
|
recordedFor: recordedProvenance(conclusion.recordedFor, this.redact),
|
|
91
|
+
...(recordedBy === undefined ? {} : { recordedBy }),
|
|
88
92
|
summary: summary.trim() === '' ? 'step passed' : summary,
|
|
89
93
|
...(startPath === undefined ? {} : { startPath }),
|
|
90
94
|
...(endPath === undefined ? {} : { endPath }),
|
|
@@ -123,8 +127,13 @@ export class TraceRecorder {
|
|
|
123
127
|
return descriptor ?? { role: 'unknown' };
|
|
124
128
|
};
|
|
125
129
|
const requireTarget = () => require(target);
|
|
126
|
-
if (isNodeAction(action))
|
|
127
|
-
|
|
130
|
+
if (isNodeAction(action)) {
|
|
131
|
+
const recorded = { name: action.name, summary, target: requireTarget() };
|
|
132
|
+
// A tap with held keys replays with them, so the trace keeps them.
|
|
133
|
+
if ('modifiers' in action && action.modifiers !== undefined)
|
|
134
|
+
return { ...recorded, modifiers: [...action.modifiers] };
|
|
135
|
+
return recorded;
|
|
136
|
+
}
|
|
128
137
|
switch (action.name) {
|
|
129
138
|
case 'check':
|
|
130
139
|
return { name: 'check', summary, target: requireTarget(), checked: action.checked };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Scalar fields shared by trace readers and the recorder. */
|
|
2
|
+
/** The configured model identity attached to a recording. */
|
|
3
|
+
export interface RecordedModel {
|
|
4
|
+
readonly provider: string;
|
|
5
|
+
readonly model: string;
|
|
6
|
+
}
|
|
7
|
+
/** Nonblank text within the field's character budget, preserved verbatim. */
|
|
8
|
+
export declare function readBoundedText(value: unknown, maxChars: number): string | undefined;
|
|
9
|
+
/** Malformed optional model attribution never makes a recording unreadable. */
|
|
10
|
+
export declare function readRecordedModel(value: unknown, maxChars: number): RecordedModel | undefined;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Scalar fields shared by trace readers and the recorder. */
|
|
2
|
+
/** Nonblank text within the field's character budget, preserved verbatim. */
|
|
3
|
+
export function readBoundedText(value, maxChars) {
|
|
4
|
+
if (typeof value !== 'string' || value.trim() === '' || value.length > maxChars) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
/** Malformed optional model attribution never makes a recording unreadable. */
|
|
10
|
+
export function readRecordedModel(value, maxChars) {
|
|
11
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
12
|
+
return undefined;
|
|
13
|
+
const raw = value;
|
|
14
|
+
const provider = readBoundedText(raw['provider'], maxChars);
|
|
15
|
+
const model = readBoundedText(raw['model'], maxChars);
|
|
16
|
+
return provider === undefined || model === undefined ? undefined : { provider, model };
|
|
17
|
+
}
|