humanish 0.40.0 → 0.42.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/README.md +36 -8
- package/dist/actor-contract.d.ts +9 -0
- package/dist/actor-contract.js.map +1 -1
- package/dist/adapter-extension.js +1 -0
- package/dist/adapter-extension.js.map +1 -1
- package/dist/computer-use-actor.d.ts +11 -0
- package/dist/computer-use-actor.js +2 -0
- package/dist/computer-use-actor.js.map +1 -1
- package/dist/computer-use.d.ts +30 -0
- package/dist/computer-use.js +65 -3
- package/dist/computer-use.js.map +1 -1
- package/dist/concurrent-shared-world-lab.js +72 -42
- package/dist/concurrent-shared-world-lab.js.map +1 -1
- package/dist/cua-actor-lab.d.ts +64 -2
- package/dist/cua-actor-lab.js +327 -28
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/e2b-desktop-executor.d.ts +1 -1
- package/dist/e2b-desktop-executor.js +2 -1
- package/dist/e2b-desktop-executor.js.map +1 -1
- package/dist/e2b-terminal-lab.js +1 -0
- package/dist/e2b-terminal-lab.js.map +1 -1
- package/dist/feedback.js +55 -1
- package/dist/feedback.js.map +1 -1
- package/dist/image-evidence.js +36 -22
- package/dist/image-evidence.js.map +1 -1
- package/dist/init-templates.js +7 -2
- package/dist/init-templates.js.map +1 -1
- package/dist/lab-config.d.ts +26 -2
- package/dist/lab-config.js +49 -2
- package/dist/lab-config.js.map +1 -1
- package/dist/observer-assets.js +11 -1
- package/dist/observer-assets.js.map +1 -1
- package/dist/observer-data.d.ts +16 -0
- package/dist/observer-data.js +14 -1
- package/dist/observer-data.js.map +1 -1
- package/dist/program.js +5 -5
- package/dist/program.js.map +1 -1
- package/dist/redaction.js +4 -9
- package/dist/redaction.js.map +1 -1
- package/dist/run.d.ts +88 -2
- package/dist/run.js +92 -3
- package/dist/run.js.map +1 -1
- package/dist/screenshot-image.d.ts +11 -0
- package/dist/screenshot-image.js +26 -0
- package/dist/screenshot-image.js.map +1 -0
- package/dist/scripted-browser-lab.js +4 -2
- package/dist/scripted-browser-lab.js.map +1 -1
- package/dist/shared-world-lab.js +17 -3
- package/dist/shared-world-lab.js.map +1 -1
- package/dist/tasks.d.ts +77 -0
- package/dist/tasks.js +101 -0
- package/dist/tasks.js.map +1 -0
- package/docs/contracts/schemas.md +1 -1
- package/docs/goals/current.md +16 -3
- package/docs/ramp/README.md +1 -1
- package/package.json +1 -1
package/dist/cua-actor-lab.js
CHANGED
|
@@ -31,7 +31,7 @@ import { CHROMIUM_EVIDENCE_HYGIENE_FLAGS, chromiumEvidenceProfilePreferencesJson
|
|
|
31
31
|
import { DEFAULT_OPENAI_CU_MODEL } from "./openai-responses-cu.js";
|
|
32
32
|
import { createDesktopSandbox, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
|
|
33
33
|
import { probeUrl, readDetachedLog, runDetachedStep, startDetachedProcess } from "./e2b-detached.js";
|
|
34
|
-
import { DEFAULT_SANDBOX_CATCH_PORT, collectCommsThread, deployCommsCatch, refreshInboxSurface, writeInboxSurface } from "./comms-sandbox-catch.js";
|
|
34
|
+
import { DEFAULT_SANDBOX_CATCH_PORT, collectCommsThread, collectExternalCommsThread, deployCommsCatch, externalCatchHealthy, externalInboxUrl, refreshInboxSurface, writeInboxSurface } from "./comms-sandbox-catch.js";
|
|
35
35
|
import { FakeInbox } from "./comms-fake-inbox.js";
|
|
36
36
|
import { buildOriginMap } from "./comms-inbox.js";
|
|
37
37
|
import { DEFAULT_DEVICE_PRESET, isDevicePresetName, resolveDevicePreset } from "./device-presets.js";
|
|
@@ -43,12 +43,13 @@ import { buildObserverData } from "./observer-data.js";
|
|
|
43
43
|
import { corepackCommandFor, needsNodeRuntime, nodeBootstrapCommand } from "./subject-runtime.js";
|
|
44
44
|
import { personaToDirectives, renderPersonaPromptSection } from "./persona.js";
|
|
45
45
|
import { labPersonaIds, resolveCommittedPersonas } from "./persona-resolve.js";
|
|
46
|
+
import { renderTaskPrompt } from "./tasks.js";
|
|
46
47
|
import { attachObserverRuntimeStreamUrls, renderObserver } from "./observer.js";
|
|
47
48
|
import { containsSensitive, digestText, redactedTail, redactText } from "./redaction.js";
|
|
48
49
|
import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, prepareContainedOutputDirectory, prepareSelectedOutputDirectory, writeContainedOutputFile, writePreparedRunLatestPointer } from "./selected-output-paths.js";
|
|
49
50
|
import { prepareRunArtifactPaths, validatePreparedRunArtifactPaths } from "./run-paths.js";
|
|
50
51
|
import { createLocalTreeArchive } from "./source-archive.js";
|
|
51
|
-
import { buildRunSource, loadRunBundle, PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA } from "./run.js";
|
|
52
|
+
import { buildRunSource, loadRunBundle, PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA, aggregateTaskFunnels, formatParticipantOutcomes, formatStudyTaskFunnel, tallyParticipantOutcomes } from "./run.js";
|
|
52
53
|
import { estimateActorCost, estimateDesktopCost, MODEL_RATES, round6 } from "./pricing.js";
|
|
53
54
|
export const CUA_ACTOR_LAB_SCHEMA = "humanish.cua-lab-result.v2";
|
|
54
55
|
// The only fan-out topology this slice ships: N lanes = N independent E2B desktop sandboxes,
|
|
@@ -61,7 +62,28 @@ export const CUA_ACTOR_LAB_PROVIDER_METADATA = {
|
|
|
61
62
|
mode: "cua-actor-lab",
|
|
62
63
|
tool: "humanish"
|
|
63
64
|
};
|
|
64
|
-
|
|
65
|
+
// The DEFAULT session budget, sized so a study can FINISH (docs/principles/three-roles.md: a
|
|
66
|
+
// session ends because the participant is done, not because a timer fired — the time-box is a
|
|
67
|
+
// session-level cap a researcher sets generously; spend protection is the dollar caps' job).
|
|
68
|
+
// The old 300s default ended real signup studies mid-flow: observed studies run 16-40 turns at
|
|
69
|
+
// ~5-6s per turn BEFORE any email wait, so five minutes was the biggest single source of
|
|
70
|
+
// budget_reached endings that read as participant failures.
|
|
71
|
+
//
|
|
72
|
+
// App-url and in-process routes default to 30 minutes. Provisioned routes (clone/local-tree)
|
|
73
|
+
// default to whatever the 1-hour sandbox cap leaves after provisioning, declared state seeding,
|
|
74
|
+
// and the teardown buffer — 20 minutes on a stateless clone — floored at the old five minutes so
|
|
75
|
+
// a state-heavy lab still gets a session at all. An EXPLICIT execution.timeoutMs is never
|
|
76
|
+
// adjusted: when it cannot be provisioned, the plan-time cap refusal shows the arithmetic.
|
|
77
|
+
const DEFAULT_APP_URL_SESSION_TIMEOUT_MS = 30 * 60_000;
|
|
78
|
+
const MIN_DERIVED_SESSION_TIMEOUT_MS = 5 * 60_000;
|
|
79
|
+
function defaultSessionTimeoutMs(config) {
|
|
80
|
+
const provisionedRoute = config.subject.source === "clone" || config.subject.source === "local-tree";
|
|
81
|
+
if (!provisionedRoute)
|
|
82
|
+
return DEFAULT_APP_URL_SESSION_TIMEOUT_MS;
|
|
83
|
+
const stateBudgetMs = (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0);
|
|
84
|
+
const room = MAX_SANDBOX_MS - SUBJECT_PROVISION_BUDGET_MS - stateBudgetMs - SANDBOX_TIMEOUT_BUFFER_MS;
|
|
85
|
+
return Math.max(MIN_DERIVED_SESSION_TIMEOUT_MS, Math.min(DEFAULT_APP_URL_SESSION_TIMEOUT_MS, room));
|
|
86
|
+
}
|
|
65
87
|
// Settle after opening the browser, before the first screenshot — long enough for a cold
|
|
66
88
|
// browser + page load to paint (2s captured a blank desktop; the render empirically needs ~6-9s).
|
|
67
89
|
const BROWSER_SETTLE_MS = 8_000;
|
|
@@ -101,6 +123,10 @@ export function composeLaneInstructions(args) {
|
|
|
101
123
|
const deviceLine = preset.isMobile
|
|
102
124
|
? `You are a mobile user on a ${name} device (${preset.width}x${preset.height} @${preset.deviceScaleFactor}x). Expect a mobile/touch layout.`
|
|
103
125
|
: `You are a desktop user (${name}, ${preset.width}x${preset.height}).`;
|
|
126
|
+
// The protocol as the PARTICIPANT reads it: numbered goals, nothing else. The success criteria
|
|
127
|
+
// are the researcher's instrument and must never reach this prompt — a persona told how it will
|
|
128
|
+
// be measured optimizes for the measurement instead of using the product (src/tasks.ts).
|
|
129
|
+
const taskLines = renderTaskPrompt(args.tasks ?? []);
|
|
104
130
|
// A resolved persona contributes its compiled directives (friction tolerance, skill bias,
|
|
105
131
|
// accessibility behavior, constraints) through the SAME persona.ts compiler the terminal lane
|
|
106
132
|
// uses, so one persona file means one behavior across every route.
|
|
@@ -112,6 +138,7 @@ export function composeLaneInstructions(args) {
|
|
|
112
138
|
personaLine,
|
|
113
139
|
deviceLine,
|
|
114
140
|
args.mission,
|
|
141
|
+
taskLines,
|
|
115
142
|
args.instruction ? `Lane focus: ${args.instruction}` : undefined
|
|
116
143
|
].filter((part) => Boolean(part));
|
|
117
144
|
const instructions = parts.join("\n\n");
|
|
@@ -212,7 +239,7 @@ export function resolveLaneDevice(config, lane) {
|
|
|
212
239
|
* git clone for an upload+extract, but the shared install/build/state/start/probe pipeline
|
|
213
240
|
* costs the same wall-clock room either way. */
|
|
214
241
|
function resolvePerLaneSandboxMs(config) {
|
|
215
|
-
const timeoutMs = config.execution?.timeoutMs ??
|
|
242
|
+
const timeoutMs = config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config);
|
|
216
243
|
const provisionedRoute = config.subject.source === "clone" || config.subject.source === "local-tree";
|
|
217
244
|
const stateBudgetMs = provisionedRoute
|
|
218
245
|
? (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0)
|
|
@@ -244,6 +271,7 @@ function laneSpecsAndPlan(config, opts = {}) {
|
|
|
244
271
|
const env = opts.env ?? {};
|
|
245
272
|
const actor = config.actors[0];
|
|
246
273
|
const mission = actor?.mission ?? DEFAULT_MISSION;
|
|
274
|
+
const tasks = actor?.tasks;
|
|
247
275
|
const roster = actor?.lanes;
|
|
248
276
|
const laneCount = roster ? roster.length : Math.max(1, opts.countOverride ?? actor?.count ?? 1);
|
|
249
277
|
const lanes = [];
|
|
@@ -257,6 +285,7 @@ function laneSpecsAndPlan(config, opts = {}) {
|
|
|
257
285
|
const resolvedPersona = personaId === undefined ? undefined : opts.personas?.get(personaId);
|
|
258
286
|
const composed = composeLaneInstructions({
|
|
259
287
|
mission,
|
|
288
|
+
...(tasks === undefined ? {} : { tasks }),
|
|
260
289
|
...(personaId === undefined ? {} : { persona: personaId }),
|
|
261
290
|
...(resolvedPersona === undefined ? {} : { resolvedPersona }),
|
|
262
291
|
...(((roster ? lane?.instruction : actor?.laneFocus?.instruction)) === undefined ? {} : { instruction: (roster ? lane?.instruction : actor?.laneFocus?.instruction) }),
|
|
@@ -274,6 +303,7 @@ function laneSpecsAndPlan(config, opts = {}) {
|
|
|
274
303
|
instructions: composed.instructions,
|
|
275
304
|
...(lane?.target === undefined ? {} : { targetUrl: lane.target }),
|
|
276
305
|
...((lane?.stopWhen ?? actor?.stopWhen) === undefined ? {} : { stopWhen: (lane?.stopWhen ?? actor?.stopWhen) }),
|
|
306
|
+
...(tasks === undefined ? {} : { tasks }),
|
|
277
307
|
deviceName: device.name,
|
|
278
308
|
devicePreset: device.preset,
|
|
279
309
|
resolution: device.resolution,
|
|
@@ -283,7 +313,7 @@ function laneSpecsAndPlan(config, opts = {}) {
|
|
|
283
313
|
}
|
|
284
314
|
const resolved = resolveCuaConcurrency(config, laneCount, env);
|
|
285
315
|
const concurrency = resolved.bound;
|
|
286
|
-
const perLaneSessionBudgetMs = config.execution?.timeoutMs ??
|
|
316
|
+
const perLaneSessionBudgetMs = config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config);
|
|
287
317
|
const perLaneSandboxMs = resolvePerLaneSandboxMs(config);
|
|
288
318
|
const plan = {
|
|
289
319
|
strategy: CUA_FANOUT_STRATEGY,
|
|
@@ -474,6 +504,20 @@ function phaseEventIdSuffix(type) {
|
|
|
474
504
|
.replace(/\.(started|completed)$/, "")
|
|
475
505
|
.replace(/\./g, "-");
|
|
476
506
|
}
|
|
507
|
+
export function makeCuaRunBudget(maxTotalUsd) {
|
|
508
|
+
const laneEstimates = new Map();
|
|
509
|
+
return {
|
|
510
|
+
maxTotalUsd,
|
|
511
|
+
note(laneId, estimateUsd) {
|
|
512
|
+
if (estimateUsd !== null)
|
|
513
|
+
laneEstimates.set(laneId, estimateUsd);
|
|
514
|
+
let total = 0;
|
|
515
|
+
for (const value of laneEstimates.values())
|
|
516
|
+
total += value;
|
|
517
|
+
return total;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
}
|
|
477
521
|
/** Build a lane's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
|
|
478
522
|
* the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
|
|
479
523
|
* at N>1). */
|
|
@@ -785,7 +829,16 @@ export function chromeCdpPortResolutionScript(endpoint) {
|
|
|
785
829
|
}
|
|
786
830
|
/** Shared CDP page-selection preamble: pinned target id first, then this lane's target URL,
|
|
787
831
|
* then a single-page fallback; never an arbitrary page from a multi-page endpoint. */
|
|
788
|
-
function chromeCdpPageSelectionScript(endpoint, targetId
|
|
832
|
+
function chromeCdpPageSelectionScript(endpoint, targetId,
|
|
833
|
+
/**
|
|
834
|
+
* "pinned" (default): the launch-time target, for measurements about the ORIGINAL window
|
|
835
|
+
* (geometry). "active": the tab the participant is driving NOW — Chrome's /json lists page
|
|
836
|
+
* targets most-recently-focused first. The state observer must follow the participant: a
|
|
837
|
+
* verification link that opens in a NEW tab left the pinned observer reading the old tab
|
|
838
|
+
* forever, so the observed URL never changed again and stopWhen/task criteria went blind
|
|
839
|
+
* (a live run's funnel read reach-dashboard 0/2 under a screenshot OF the dashboard).
|
|
840
|
+
*/
|
|
841
|
+
prefer = "pinned") {
|
|
789
842
|
return [
|
|
790
843
|
...chromeCdpPortResolutionScript(endpoint),
|
|
791
844
|
"const pages = await fetch('http://127.0.0.1:' + cdpPort + '/json').then((r) => r.json()).catch(() => []);",
|
|
@@ -793,22 +846,27 @@ function chromeCdpPageSelectionScript(endpoint, targetId) {
|
|
|
793
846
|
`const expectedTargetUrl = ${JSON.stringify(endpoint.targetUrl)};`,
|
|
794
847
|
"const normalizeUrl = (value) => String(value || '').replace(/\\/$/, '');",
|
|
795
848
|
"const httpPages = Array.isArray(pages) ? pages.filter((entry) => entry && entry.type === 'page' && /^https?:/.test(String(entry.url || ''))) : [];",
|
|
796
|
-
|
|
849
|
+
prefer === "active"
|
|
850
|
+
? "const page = httpPages[0] || (expectedTargetId ? httpPages.find((entry) => entry.id === expectedTargetId) : undefined);"
|
|
851
|
+
: "const page = expectedTargetId ? httpPages.find((entry) => entry.id === expectedTargetId) : (httpPages.find((entry) => normalizeUrl(entry.url) === normalizeUrl(expectedTargetUrl)) || (httpPages.length === 1 ? httpPages[0] : undefined));"
|
|
797
852
|
];
|
|
798
853
|
}
|
|
799
854
|
export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoint, targetId) {
|
|
800
855
|
return async () => {
|
|
801
856
|
const script = [
|
|
802
|
-
|
|
857
|
+
// "active": follow the participant to whatever tab they are driving now — never pin the
|
|
858
|
+
// state observer to the launch tab (see chromeCdpPageSelectionScript).
|
|
859
|
+
...chromeCdpPageSelectionScript(endpoint, targetId, "active"),
|
|
803
860
|
"if (!page) { console.log('{}'); process.exit(0); }",
|
|
804
861
|
"let text = '';",
|
|
862
|
+
"let scrollY = undefined;",
|
|
805
863
|
"let url = String(page.url || '');",
|
|
806
864
|
"let title = String(page.title || '');",
|
|
807
865
|
"if (typeof WebSocket === 'function' && page.webSocketDebuggerUrl) {",
|
|
808
866
|
" const ws = new WebSocket(page.webSocketDebuggerUrl);",
|
|
809
867
|
" const result = await new Promise((resolve) => {",
|
|
810
868
|
" const timer = setTimeout(() => resolve(undefined), 1500);",
|
|
811
|
-
" ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { returnByValue: true, expression: '({ url: location.href, title: document.title, text: (document.body && document.body.innerText || \"\").slice(0, 20000) })' } }));",
|
|
869
|
+
" ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { returnByValue: true, expression: '({ url: location.href, title: document.title, text: (document.body && document.body.innerText || \"\").slice(0, 20000), scrollY: (window.scrollY || 0) })' } }));",
|
|
812
870
|
" ws.onmessage = (event) => {",
|
|
813
871
|
" try {",
|
|
814
872
|
" const payload = JSON.parse(String(event.data));",
|
|
@@ -823,9 +881,10 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
|
|
|
823
881
|
" url = typeof result.url === 'string' ? result.url : url;",
|
|
824
882
|
" title = typeof result.title === 'string' ? result.title : title;",
|
|
825
883
|
" text = typeof result.text === 'string' ? result.text : '';",
|
|
884
|
+
" scrollY = typeof result.scrollY === 'number' ? result.scrollY : undefined;",
|
|
826
885
|
" }",
|
|
827
886
|
"}",
|
|
828
|
-
"console.log(JSON.stringify({ url, title, text }));"
|
|
887
|
+
"console.log(JSON.stringify({ url, title, text, scrollY }));"
|
|
829
888
|
].join("\n");
|
|
830
889
|
const result = await desktop.commands.run(`node --input-type=module -e ${shellSingleQuote(script)}`, {
|
|
831
890
|
requestTimeoutMs,
|
|
@@ -843,7 +902,8 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
|
|
|
843
902
|
return {
|
|
844
903
|
...(typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {}),
|
|
845
904
|
...(typeof record.title === "string" && record.title.length > 0 ? { title: record.title } : {}),
|
|
846
|
-
...(typeof record.text === "string" && record.text.length > 0 ? { text: record.text } : {})
|
|
905
|
+
...(typeof record.text === "string" && record.text.length > 0 ? { text: record.text } : {}),
|
|
906
|
+
...(typeof record.scrollY === "number" && Number.isFinite(record.scrollY) ? { scrollY: record.scrollY } : {})
|
|
847
907
|
};
|
|
848
908
|
}
|
|
849
909
|
catch {
|
|
@@ -1380,10 +1440,14 @@ export async function runCuaLane(spec, deps) {
|
|
|
1380
1440
|
const maxUsd = config.execution?.caps?.maxUsd;
|
|
1381
1441
|
const sessionOptions = {
|
|
1382
1442
|
// Tell the persona where its inbox is — but only when comms is live AND this lane has a declared
|
|
1383
|
-
// recipient it can actually receive mail into (else it would stall on an inbox that stays
|
|
1443
|
+
// recipient it can actually receive mail into (else it would stall on an inbox that stays
|
|
1444
|
+
// empty). Two comms planes, mutually exclusive by parse: the in-sandbox catch humanish
|
|
1445
|
+
// deployed, or the adopter-hosted one (#380).
|
|
1384
1446
|
instructions: commsEmail && commsInboxUrl && deployedComms?.ready && laneHasInboxRecipient(commsEmail, spec.laneId)
|
|
1385
1447
|
? withInboxMission(spec, commsInboxUrl, inboxRecipientFor(commsEmail, spec.laneId)?.address).instructions
|
|
1386
|
-
: spec.
|
|
1448
|
+
: deps.externalComms && laneHasInboxRecipient(deps.externalComms.email, spec.laneId)
|
|
1449
|
+
? withInboxMission(spec, deps.externalComms.inboxUrl, inboxRecipientFor(deps.externalComms.email, spec.laneId)?.address).instructions
|
|
1450
|
+
: spec.instructions,
|
|
1387
1451
|
persona: spec.persona,
|
|
1388
1452
|
timeoutMs: deps.timeoutMs,
|
|
1389
1453
|
openai: {
|
|
@@ -1414,6 +1478,24 @@ export async function runCuaLane(spec, deps) {
|
|
|
1414
1478
|
...(spec.idleSteps === undefined ? {} : { idleSteps: spec.idleSteps }),
|
|
1415
1479
|
...(spec.noProgressSteps === undefined ? {} : { noProgressSteps: spec.noProgressSteps }),
|
|
1416
1480
|
...(spec.stopWhen === undefined ? {} : { stopWhen: spec.stopWhen }),
|
|
1481
|
+
...(spec.tasks === undefined ? {} : { tasks: spec.tasks }),
|
|
1482
|
+
// The STUDY budget (#299): this lane notes its own running estimate on the shared ledger
|
|
1483
|
+
// and stops when the RUN total crosses the cap — independent of the per-lane maxUsd above.
|
|
1484
|
+
...(deps.runBudget === undefined
|
|
1485
|
+
? {}
|
|
1486
|
+
: {
|
|
1487
|
+
overRunBudget: (usage) => {
|
|
1488
|
+
const estimate = estimateActorCost({
|
|
1489
|
+
input: usage.input,
|
|
1490
|
+
output: usage.output,
|
|
1491
|
+
...(usage.cachedInput > 0 ? { cachedInput: usage.cachedInput } : {})
|
|
1492
|
+
}, capModelId).estimatedCostUsd;
|
|
1493
|
+
const totalUsd = deps.runBudget.note(spec.laneId, estimate);
|
|
1494
|
+
return totalUsd > deps.runBudget.maxTotalUsd
|
|
1495
|
+
? `study budget reached: the run's estimated model spend $${round6(totalUsd)} crossed execution.caps.maxTotalUsd=$${deps.runBudget.maxTotalUsd}; this lane stops here and sibling lanes stop at their next turn`
|
|
1496
|
+
: null;
|
|
1497
|
+
}
|
|
1498
|
+
}),
|
|
1417
1499
|
...(deps.onObservedUrl === undefined ? {} : { onObservedUrl: deps.onObservedUrl }),
|
|
1418
1500
|
...(deps.onMessage === undefined ? {} : { onMessage: deps.onMessage }),
|
|
1419
1501
|
...(deps.onScreenshot === undefined ? {} : { onScreenshot: deps.onScreenshot })
|
|
@@ -1626,7 +1708,8 @@ async function runInProcessLane(spec, deps) {
|
|
|
1626
1708
|
redactScreenshots: deps.redactScreenshots,
|
|
1627
1709
|
scrubText: deps.scrubKnownValues,
|
|
1628
1710
|
writeScreenshot,
|
|
1629
|
-
...(spec.stopWhen === undefined ? {} : { stopWhen: spec.stopWhen })
|
|
1711
|
+
...(spec.stopWhen === undefined ? {} : { stopWhen: spec.stopWhen }),
|
|
1712
|
+
...(spec.tasks === undefined ? {} : { tasks: spec.tasks })
|
|
1630
1713
|
};
|
|
1631
1714
|
session = await deps.runSession(sessionOptions);
|
|
1632
1715
|
}
|
|
@@ -1960,6 +2043,15 @@ export async function runCuaActorLab(options) {
|
|
|
1960
2043
|
const runSession = hooks.runSession ?? descriptor.runSession;
|
|
1961
2044
|
const inProcessRoute = hooks.buildExecutor !== undefined;
|
|
1962
2045
|
const localAppSubject = config.subject.source === "local-app";
|
|
2046
|
+
// Adopter-hosted comms plane on the app-url route (#380): humanish provisions no subject here,
|
|
2047
|
+
// so it cannot host a catch — the OPERATOR runs one, and humanish still does every other part
|
|
2048
|
+
// of the funnel: tells each persona its address and inbox URL, drains the catch over HTTP after
|
|
2049
|
+
// the lanes, and writes the same digest-only evidence. Declaring `external` previously did
|
|
2050
|
+
// nothing on this route (and, per #387, on every other) while its docs said otherwise.
|
|
2051
|
+
const externalCommsConfig = !cloneRoute && !localTreeRoute && !inProcessRoute
|
|
2052
|
+
? config.comms?.email?.external
|
|
2053
|
+
: undefined;
|
|
2054
|
+
const externalCommsEmail = externalCommsConfig ? config.comms?.email : undefined;
|
|
1963
2055
|
// Engine re-enforcement of the clone-route structure (library API surface).
|
|
1964
2056
|
if (cloneRoute && (!serve || !subjectRepo || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(subjectRepo))) {
|
|
1965
2057
|
return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", !serve
|
|
@@ -2017,7 +2109,7 @@ export async function runCuaActorLab(options) {
|
|
|
2017
2109
|
const derivedSandboxMs = resolvePerLaneSandboxMs(config);
|
|
2018
2110
|
if (derivedSandboxMs > MAX_SANDBOX_MS) {
|
|
2019
2111
|
const provisionedRoute = config.subject.source === "clone" || config.subject.source === "local-tree";
|
|
2020
|
-
const sessionMs = config.execution?.timeoutMs ??
|
|
2112
|
+
const sessionMs = config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config);
|
|
2021
2113
|
const headroomMs = derivedSandboxMs - sessionMs;
|
|
2022
2114
|
return fail("HUMANISH_CUA_LAB_SUBJECT_INVALID", `execution.timeoutMs ${Math.round(sessionMs / 60_000)}m derives a ${Math.round(derivedSandboxMs / 60_000)}m sandbox deadline, and a sandbox may not live longer than ${MAX_SANDBOX_MS / 60_000}m. The deadline is the session budget plus ${Math.round(headroomMs / 60_000)}m of provisioning and teardown headroom${provisionedRoute ? " (this route clones, installs, builds and serves the subject before the actor starts)" : ""}. Lower execution.timeoutMs to at most ${Math.round((MAX_SANDBOX_MS - headroomMs) / 60_000)}m, or set execution.desktop.sandboxTimeoutMs explicitly.`, descriptor.id);
|
|
2023
2115
|
}
|
|
@@ -2099,19 +2191,26 @@ export async function runCuaActorLab(options) {
|
|
|
2099
2191
|
// runaway-retry protection. Refuse at PREFLIGHT (before any sandbox/spend) rather than run
|
|
2100
2192
|
// uncapped: an unenforceable cap is more dangerous than none. The operator adds a rate to
|
|
2101
2193
|
// src/pricing.ts (the honest place) or removes the cap.
|
|
2102
|
-
if (config.execution?.caps?.maxUsd !== undefined) {
|
|
2194
|
+
if (config.execution?.caps?.maxUsd !== undefined || config.execution?.caps?.maxTotalUsd !== undefined) {
|
|
2103
2195
|
const capModelId = (config.actors[0]?.model ?? DEFAULT_OPENAI_CU_MODEL).trim().toLowerCase();
|
|
2104
2196
|
if (!MODEL_RATES[capModelId]) {
|
|
2105
|
-
return fail("HUMANISH_CUA_LAB_UNPRICED_CAP", `execution.caps
|
|
2197
|
+
return fail("HUMANISH_CUA_LAB_UNPRICED_CAP", `execution.caps declares a spend cap (maxUsd/maxTotalUsd) but src/pricing.ts has no rate for model "${config.actors[0]?.model ?? DEFAULT_OPENAI_CU_MODEL}"; add a rate or remove the cap — an unenforceable cap is refused rather than run uncapped.`, descriptor.id);
|
|
2106
2198
|
}
|
|
2107
2199
|
}
|
|
2200
|
+
// Adopter-hosted comms catch (#380): fail closed BEFORE any sandbox is created — a comms lab
|
|
2201
|
+
// whose catch is unreachable collects nothing while every lane still spends. The probe asserts
|
|
2202
|
+
// OUR service marker in /health, so an adopter's proxy answering 200 for everything cannot
|
|
2203
|
+
// pass for a catch.
|
|
2204
|
+
if (externalCommsConfig && !(await externalCatchHealthy(externalCommsConfig))) {
|
|
2205
|
+
return fail("HUMANISH_CUA_LAB_COMMS_CATCH_UNREACHABLE", "comms.email.external.catchBaseUrl is not reachable as a humanish comms catch (GET /health must return the humanish-comms-catch service marker). Start it with `humanish comms catch` on that host, or drop comms.email to run without the inbox funnel.", descriptor.id);
|
|
2206
|
+
}
|
|
2108
2207
|
}
|
|
2109
2208
|
const runId = options.runId ?? makeCuaRunId();
|
|
2110
2209
|
const runPaths = await prepareRunArtifactPaths(cwd, runId);
|
|
2111
2210
|
const artifactRoot = runPaths.absoluteRunRoot;
|
|
2112
2211
|
const physicalArtifactRoot = runPaths.physicalRunRoot;
|
|
2113
2212
|
const createdAt = new Date().toISOString();
|
|
2114
|
-
const timeoutMs = config.execution?.timeoutMs ??
|
|
2213
|
+
const timeoutMs = config.execution?.timeoutMs ?? defaultSessionTimeoutMs(config);
|
|
2115
2214
|
const requestTimeoutMs = readPositiveInt(env.HUMANISH_E2B_REQUEST_TIMEOUT_MS, 60_000);
|
|
2116
2215
|
const redactScreenshots = config.policies?.redactScreenshots === true;
|
|
2117
2216
|
await prepareContainedOutputDirectory(runPaths, "screenshots");
|
|
@@ -2169,6 +2268,14 @@ export async function runCuaActorLab(options) {
|
|
|
2169
2268
|
redactScreenshots,
|
|
2170
2269
|
scrubKnownValues,
|
|
2171
2270
|
runSession,
|
|
2271
|
+
// The study-level ledger exists once per RUN, shared by every lane (#299). Dry runs never
|
|
2272
|
+
// spend, so they carry none.
|
|
2273
|
+
...(dryRun || config.execution?.caps?.maxTotalUsd === undefined
|
|
2274
|
+
? {}
|
|
2275
|
+
: { runBudget: makeCuaRunBudget(config.execution.caps.maxTotalUsd) }),
|
|
2276
|
+
...(externalCommsConfig === undefined || externalCommsEmail === undefined
|
|
2277
|
+
? {}
|
|
2278
|
+
: { externalComms: { email: externalCommsEmail, inboxUrl: externalInboxUrl(externalCommsConfig) } }),
|
|
2172
2279
|
now: hooks.now ?? Date.now,
|
|
2173
2280
|
hooks: liveHooks
|
|
2174
2281
|
};
|
|
@@ -2245,6 +2352,47 @@ export async function runCuaActorLab(options) {
|
|
|
2245
2352
|
failFastReason = ran.failFastReason;
|
|
2246
2353
|
}
|
|
2247
2354
|
}
|
|
2355
|
+
const externalCommsWarnings = [];
|
|
2356
|
+
// Adopter-hosted drain (#380): once per RUN, after every lane finished — the catch is one
|
|
2357
|
+
// shared external endpoint, not a per-sandbox file. Same routing and digest-only artifact as
|
|
2358
|
+
// the in-sandbox drain; the artifact is registered on every lane that declared a recipient
|
|
2359
|
+
// address, since the thread carries each inbox's mail. A drain failure never fails the run.
|
|
2360
|
+
if (!dryRun && externalCommsConfig && externalCommsEmail && outcomes !== undefined) {
|
|
2361
|
+
try {
|
|
2362
|
+
const commsChannel = new FakeInbox();
|
|
2363
|
+
const commsInboxes = [];
|
|
2364
|
+
for (const recipient of externalCommsEmail.recipients ?? []) {
|
|
2365
|
+
if (recipient.address !== undefined) {
|
|
2366
|
+
commsInboxes.push(await commsChannel.provisionAddress(recipient.lane, recipient.address));
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
const authToken = externalCommsConfig.authTokenEnv === undefined ? undefined : env[externalCommsConfig.authTokenEnv];
|
|
2370
|
+
const collected = await collectExternalCommsThread({
|
|
2371
|
+
external: { ...externalCommsConfig, ...(authToken === undefined ? {} : { authToken }) },
|
|
2372
|
+
channel: commsChannel,
|
|
2373
|
+
inboxes: commsInboxes
|
|
2374
|
+
});
|
|
2375
|
+
if (collected.artifact) {
|
|
2376
|
+
const commsPath = "comms/thread.json";
|
|
2377
|
+
await writeContainedOutputFile(runPaths, commsPath, `${JSON.stringify(collected.artifact, null, 2)}\n`, "utf8");
|
|
2378
|
+
for (const [index, outcome] of outcomes.entries()) {
|
|
2379
|
+
const laneId = laneSpecs[index]?.laneId;
|
|
2380
|
+
if (laneId !== undefined && outcome.commsArtifactPath === undefined && laneHasInboxRecipient(externalCommsEmail, laneId)) {
|
|
2381
|
+
outcome.commsArtifactPath = commsPath;
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
else if (collected.captured > 0) {
|
|
2386
|
+
externalCommsWarnings.push(`Comms catch captured ${collected.captured} email send(s) but none matched a declared recipient inbox — no comms evidence written. Declare comms.email.recipients[].address to match the address the app sends to.`);
|
|
2387
|
+
}
|
|
2388
|
+
else {
|
|
2389
|
+
externalCommsWarnings.push(`Comms catch captured ZERO email sends — your app never delivered mail through the catch at ${externalCommsConfig.catchBaseUrl}. Verify the app's email-API base URL points at it and that the flow reached an email step.`);
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
catch (error) {
|
|
2393
|
+
externalCommsWarnings.push(`Comms evidence collection failed against the adopter-hosted catch (run continues): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2248
2396
|
// Per-lane subject projections (invariant 5).
|
|
2249
2397
|
const laneSubjects = laneSpecs.map((_spec, index) => {
|
|
2250
2398
|
const outcome = outcomes?.[index];
|
|
@@ -2268,14 +2416,14 @@ export async function runCuaActorLab(options) {
|
|
|
2268
2416
|
// lane's projection already carries the identical archiveSha256/commit/dirty: the
|
|
2269
2417
|
// `first.source !== "clone"` branch below returns it directly, with no unanimity math needed
|
|
2270
2418
|
// (there is nothing that could diverge).
|
|
2271
|
-
const aggregateWarnings = [];
|
|
2419
|
+
const aggregateWarnings = [...externalCommsWarnings];
|
|
2272
2420
|
// execution.caps.maxUsd is a PER-LANE cap: it is enforced INSIDE each lane's loop independently,
|
|
2273
2421
|
// so an N-lane fan-out can spend up to N × maxUsd before any lane aborts, while the run cost
|
|
2274
|
-
// summary reports the (larger) aggregate. Warn at run level so the operator sees the true
|
|
2275
|
-
//
|
|
2422
|
+
// summary reports the (larger) aggregate. Warn at run level so the operator sees the true
|
|
2423
|
+
// ceiling — unless the study declared the shared budget (#299), which caps the run as a whole.
|
|
2276
2424
|
const perLaneCapUsd = config.execution?.caps?.maxUsd;
|
|
2277
|
-
if (perLaneCapUsd !== undefined && laneCount > 1) {
|
|
2278
|
-
aggregateWarnings.push(`execution.caps.maxUsd ($${perLaneCapUsd}) is a PER-LANE cap; ${laneCount} lanes may spend up to ${laneCount} × $${perLaneCapUsd} (~$${round6(perLaneCapUsd * laneCount)} total) before any lane aborts.
|
|
2425
|
+
if (perLaneCapUsd !== undefined && laneCount > 1 && config.execution?.caps?.maxTotalUsd === undefined) {
|
|
2426
|
+
aggregateWarnings.push(`execution.caps.maxUsd ($${perLaneCapUsd}) is a PER-LANE cap; ${laneCount} lanes may spend up to ${laneCount} × $${perLaneCapUsd} (~$${round6(perLaneCapUsd * laneCount)} total) before any lane aborts. Set execution.caps.maxTotalUsd for a shared study budget.`);
|
|
2279
2427
|
}
|
|
2280
2428
|
const aggregateSubject = (() => {
|
|
2281
2429
|
const first = laneSubjects[0];
|
|
@@ -3006,6 +3154,80 @@ export function buildCuaCostSummary(args) {
|
|
|
3006
3154
|
function desktopSpanToMinutes(desktopDurationMs) {
|
|
3007
3155
|
return desktopDurationMs === undefined ? undefined : desktopDurationMs / 60_000;
|
|
3008
3156
|
}
|
|
3157
|
+
/**
|
|
3158
|
+
* Feedback candidates derived from what LIVE participants actually reported (#392).
|
|
3159
|
+
*
|
|
3160
|
+
* A live run's feedback draft used to fall through to a dry-run template, because no browser route
|
|
3161
|
+
* ever built a candidate. The candidate worth filing is the one the study produced: a participant
|
|
3162
|
+
* who reported friction on the way (the most valuable thing a run captures), or one who stopped
|
|
3163
|
+
* trying. A clean pass files nothing here — feedback exists to carry findings, and a run without
|
|
3164
|
+
* any falls back to an honest live summary in the draft layer instead of a template.
|
|
3165
|
+
*
|
|
3166
|
+
* Everything quoted is already scrub+redacted — `session.reason` passes through redactNarration in
|
|
3167
|
+
* the loop before it ever lands on a trace — and passes redactText again here as defense-in-depth.
|
|
3168
|
+
*/
|
|
3169
|
+
export function participantFeedbackCandidates(args) {
|
|
3170
|
+
const candidates = [];
|
|
3171
|
+
for (const lane of args.lanes) {
|
|
3172
|
+
const session = lane.session;
|
|
3173
|
+
if (session === undefined)
|
|
3174
|
+
continue;
|
|
3175
|
+
const friction = resolveSelfReportedBlocker(session);
|
|
3176
|
+
const abandoned = session.status === "abandoned";
|
|
3177
|
+
if (friction === undefined && !abandoned)
|
|
3178
|
+
continue;
|
|
3179
|
+
const summary = friction !== undefined
|
|
3180
|
+
? `Participant ${lane.personaId} (${lane.laneId}) reported friction on the way through the study goal`
|
|
3181
|
+
: `Participant ${lane.personaId} (${lane.laneId}) stopped before completing the study goal`;
|
|
3182
|
+
const lastScreenshot = lane.screenshots[lane.screenshots.length - 1];
|
|
3183
|
+
candidates.push({
|
|
3184
|
+
schema: "humanish.feedback-candidate.v1",
|
|
3185
|
+
id: `participant-report-${lane.laneId}`,
|
|
3186
|
+
run_id: args.runId,
|
|
3187
|
+
stream_id: lane.streamId,
|
|
3188
|
+
adapter_id: args.adapterId,
|
|
3189
|
+
scenario_id: args.scenarioId,
|
|
3190
|
+
persona_id: lane.personaId,
|
|
3191
|
+
actor: "computer-use",
|
|
3192
|
+
substrate: args.substrate,
|
|
3193
|
+
// The participant is reporting on the PRODUCT: friction and abandonment are target-app
|
|
3194
|
+
// findings by the three-roles rule. A harness failure never reaches this builder — it is
|
|
3195
|
+
// not a participant report.
|
|
3196
|
+
failure_owner: "target-app",
|
|
3197
|
+
summary,
|
|
3198
|
+
expected: args.goal,
|
|
3199
|
+
actual: redactText(friction ?? session.reason),
|
|
3200
|
+
evidence: [
|
|
3201
|
+
...(lane.traceArtifactPath === undefined ? [] : [{
|
|
3202
|
+
path: lane.traceArtifactPath,
|
|
3203
|
+
kind: "trace",
|
|
3204
|
+
note: "Full actor trace: turns, actions, and the participant's own report."
|
|
3205
|
+
}]),
|
|
3206
|
+
...(lastScreenshot === undefined ? [] : [{
|
|
3207
|
+
path: lastScreenshot,
|
|
3208
|
+
kind: "screenshot",
|
|
3209
|
+
note: "Final screenshot at the moment the session ended."
|
|
3210
|
+
}]),
|
|
3211
|
+
...(lane.commsArtifactPath === undefined ? [] : [{
|
|
3212
|
+
path: lane.commsArtifactPath,
|
|
3213
|
+
kind: "log",
|
|
3214
|
+
note: "Digest-only comms thread captured in-sandbox."
|
|
3215
|
+
}])
|
|
3216
|
+
],
|
|
3217
|
+
redaction: {
|
|
3218
|
+
status: "passed",
|
|
3219
|
+
notes: "Quoted participant text passed the loop's known-value scrub and pattern redaction before persisting, and redactText again here."
|
|
3220
|
+
},
|
|
3221
|
+
idempotency_key: `humanish:${args.runId}:${lane.laneId}:participant-report`,
|
|
3222
|
+
proposed_next_state: "study-quality-review",
|
|
3223
|
+
acceptance_proof: [
|
|
3224
|
+
`pnpm humanish -- verify --run ${args.runId} --json`,
|
|
3225
|
+
`pnpm humanish -- watch --run ${args.runId} --no-open`
|
|
3226
|
+
]
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
3229
|
+
return candidates;
|
|
3230
|
+
}
|
|
3009
3231
|
export function buildCuaBundle(args) {
|
|
3010
3232
|
const publicAppUrl = publicSafeAppUrlLabel(args.appUrl);
|
|
3011
3233
|
// Run-level cost ESTIMATE (advisory; omitted when nothing was priced and no sandbox ran).
|
|
@@ -3212,6 +3434,11 @@ export function buildCuaBundle(args) {
|
|
|
3212
3434
|
streamId: "stream-001"
|
|
3213
3435
|
});
|
|
3214
3436
|
}
|
|
3437
|
+
// A funnel with a denominator of one is still the funnel — and its absence stays honest: no
|
|
3438
|
+
// declared protocol (or a dry run) means no `tasks` field, never an empty one.
|
|
3439
|
+
const singleStudyTasks = args.inProgress !== true && args.session?.trace.taskFunnel !== undefined
|
|
3440
|
+
? aggregateTaskFunnels([args.session.trace.taskFunnel])
|
|
3441
|
+
: undefined;
|
|
3215
3442
|
const review = {
|
|
3216
3443
|
schema: REVIEW_SCHEMA,
|
|
3217
3444
|
verdict: args.inProgress === true
|
|
@@ -3221,6 +3448,12 @@ export function buildCuaBundle(args) {
|
|
|
3221
3448
|
: args.sessionError
|
|
3222
3449
|
? "fail"
|
|
3223
3450
|
: "contract_proof_only",
|
|
3451
|
+
// One lane is still a study with a denominator of one, and saying so keeps a single-lane
|
|
3452
|
+
// result from being read as though it generalized.
|
|
3453
|
+
...(args.session && args.inProgress !== true
|
|
3454
|
+
? { participants: tallyParticipantOutcomes([args.session.status]) }
|
|
3455
|
+
: {}),
|
|
3456
|
+
...(singleStudyTasks === undefined ? {} : { tasks: singleStudyTasks }),
|
|
3224
3457
|
summary: reason,
|
|
3225
3458
|
gaps: args.session || args.sessionError
|
|
3226
3459
|
? []
|
|
@@ -3246,7 +3479,7 @@ export function buildCuaBundle(args) {
|
|
|
3246
3479
|
scenario: {
|
|
3247
3480
|
id: `cua-${args.labId}`,
|
|
3248
3481
|
title: args.labTitle ?? `Computer-use lab: ${args.labId}`,
|
|
3249
|
-
goal: args.mission,
|
|
3482
|
+
goal: redactText(args.mission),
|
|
3250
3483
|
source: `lab:${args.labId}`,
|
|
3251
3484
|
sourceDigest: args.persona.promptDigest
|
|
3252
3485
|
},
|
|
@@ -3278,7 +3511,26 @@ export function buildCuaBundle(args) {
|
|
|
3278
3511
|
events: "events.ndjson"
|
|
3279
3512
|
},
|
|
3280
3513
|
review,
|
|
3281
|
-
|
|
3514
|
+
// What the participant reported, when it reported anything (#392). Dry-run and in-progress
|
|
3515
|
+
// bundles carry none — there is no participant yet to quote.
|
|
3516
|
+
feedbackCandidates: args.dryRun || args.inProgress === true
|
|
3517
|
+
? []
|
|
3518
|
+
: participantFeedbackCandidates({
|
|
3519
|
+
runId: args.runId,
|
|
3520
|
+
scenarioId: `cua-${args.labId}`,
|
|
3521
|
+
adapterId: args.labId,
|
|
3522
|
+
goal: redactText(args.mission),
|
|
3523
|
+
substrate: args.desktopRoute === false ? "local-filesystem" : "e2b-desktop",
|
|
3524
|
+
lanes: [{
|
|
3525
|
+
laneId: args.laneId ?? "lane-01",
|
|
3526
|
+
streamId: "stream-001",
|
|
3527
|
+
personaId: args.persona.id,
|
|
3528
|
+
...(args.session === undefined ? {} : { session: args.session }),
|
|
3529
|
+
...(args.traceArtifactPath === undefined ? {} : { traceArtifactPath: args.traceArtifactPath }),
|
|
3530
|
+
screenshots: args.screenshots,
|
|
3531
|
+
...(args.commsArtifactPath === undefined ? {} : { commsArtifactPath: args.commsArtifactPath })
|
|
3532
|
+
}]
|
|
3533
|
+
}),
|
|
3282
3534
|
// Custom desktop image provenance (omitted on the stock-template default → byte-stable).
|
|
3283
3535
|
...(args.desktopTemplate === undefined ? {} : { desktopTemplate: args.desktopTemplate }),
|
|
3284
3536
|
...(args.desktopBrowser === undefined ? {} : { desktopBrowser: args.desktopBrowser }),
|
|
@@ -3614,14 +3866,31 @@ export function buildCuaFanoutBundle(args) {
|
|
|
3614
3866
|
&& outcome.sessionError === undefined
|
|
3615
3867
|
&& !outcome.noEngagement
|
|
3616
3868
|
&& !outcome.selfReportedBlocker).length;
|
|
3869
|
+
// What happened to the PARTICIPANTS, with the denominator attached. The verdict above has to
|
|
3870
|
+
// collapse the run to one word; this does not (docs/principles/three-roles.md).
|
|
3871
|
+
const terminalOutcomes = (outcomes ?? []).filter((outcome) => outcome?.session?.status !== undefined);
|
|
3872
|
+
const participants = terminalOutcomes.length > 0
|
|
3873
|
+
? tallyParticipantOutcomes(terminalOutcomes.map((outcome) => outcome.session.status),
|
|
3874
|
+
// A participant who reached the goal AND told you the road there was broken is the most
|
|
3875
|
+
// useful result a study produces; reporting only the outcome would bury it.
|
|
3876
|
+
terminalOutcomes.map((outcome) => outcome.selfReportedBlocker === true))
|
|
3877
|
+
: undefined;
|
|
3878
|
+
// The study funnel: per-task completion rates across every session that measured one. This is
|
|
3879
|
+
// "where did people get stuck" as data, next to WHO got stuck (participants) above.
|
|
3880
|
+
const participantFunnels = (outcomes ?? [])
|
|
3881
|
+
.map((outcome) => outcome?.session?.trace.taskFunnel)
|
|
3882
|
+
.filter((funnel) => funnel !== undefined);
|
|
3883
|
+
const studyTasks = args.inProgress === true ? undefined : aggregateTaskFunnels(participantFunnels);
|
|
3617
3884
|
const review = {
|
|
3618
3885
|
schema: REVIEW_SCHEMA,
|
|
3619
3886
|
verdict,
|
|
3887
|
+
...(participants === undefined ? {} : { participants }),
|
|
3888
|
+
...(studyTasks === undefined ? {} : { tasks: studyTasks }),
|
|
3620
3889
|
summary: args.inProgress === true
|
|
3621
3890
|
? `Live computer-use fan-out is running (${specs.length} per-lane worlds); terminal lane evidence has not been written yet.`
|
|
3622
3891
|
: args.dryRun
|
|
3623
3892
|
? `${args.rerun ? `Rerun contract from ${args.rerun.sourceRunId}: ` : ""}Dry-run fan-out contract: ${specs.length} per-lane-world lanes composed for ${args.descriptor.id} against ${args.appUrl}; no desktops launched, $0 spend.`
|
|
3624
|
-
: `${args.rerun ? `Rerun from ${args.rerun.sourceRunId}: ` : ""}Computer-use fan-out (${specs.length} per-lane worlds): ${passedLanes}/${specs.length} lane(s) reached a terminal, engaged verdict.`,
|
|
3893
|
+
: `${args.rerun ? `Rerun from ${args.rerun.sourceRunId}: ` : ""}Computer-use fan-out (${specs.length} per-lane worlds): ${passedLanes}/${specs.length} lane(s) reached a terminal, engaged verdict${participants ? ` — ${formatParticipantOutcomes(participants)}` : ""}${studyTasks ? `; tasks: ${formatStudyTaskFunnel(studyTasks)}` : ""}.`,
|
|
3625
3894
|
gaps: args.inProgress === true
|
|
3626
3895
|
? ["Live fan-out session is still running."]
|
|
3627
3896
|
: args.dryRun
|
|
@@ -3682,7 +3951,15 @@ export function buildCuaFanoutBundle(args) {
|
|
|
3682
3951
|
scenario: {
|
|
3683
3952
|
id: `cua-${config.id}`,
|
|
3684
3953
|
title: config.title ?? `Computer-use fan-out: ${config.id}`,
|
|
3685
|
-
|
|
3954
|
+
// Redacted at WRITE time, like every other raw-text surface in the bundle. Lane records are
|
|
3955
|
+
// digest-only by design, but scenario.goal keeps one lane's composed instructions verbatim —
|
|
3956
|
+
// and an adopter whose authored lane text must name a runtime world URL (an inbox on a route
|
|
3957
|
+
// where the harness does not inject one) put an *.e2b.app address in it. That landed raw here
|
|
3958
|
+
// and in observer-data.json, the sensitive-text scanner matched it, and verify failed a bundle
|
|
3959
|
+
// this writer produced. The only adopter-side workaround was scanner evasion (#412).
|
|
3960
|
+
//
|
|
3961
|
+
// The instructions the model actually receives are untouched; only the persisted copy changes.
|
|
3962
|
+
goal: redactText(specs[0].instructions),
|
|
3686
3963
|
source: `lab:${config.id}`,
|
|
3687
3964
|
sourceDigest: specs[0].persona.promptDigest
|
|
3688
3965
|
},
|
|
@@ -3713,7 +3990,29 @@ export function buildCuaFanoutBundle(args) {
|
|
|
3713
3990
|
events: "events.ndjson"
|
|
3714
3991
|
},
|
|
3715
3992
|
review,
|
|
3716
|
-
|
|
3993
|
+
// What the participants reported, when any reported anything (#392). Dry-run and in-progress
|
|
3994
|
+
// bundles carry none — there is no participant yet to quote.
|
|
3995
|
+
feedbackCandidates: args.dryRun || args.inProgress === true
|
|
3996
|
+
? []
|
|
3997
|
+
: participantFeedbackCandidates({
|
|
3998
|
+
runId: args.runId,
|
|
3999
|
+
scenarioId: `cua-${config.id}`,
|
|
4000
|
+
adapterId: config.id,
|
|
4001
|
+
goal: redactText(specs[0].instructions),
|
|
4002
|
+
substrate: "e2b-desktop",
|
|
4003
|
+
lanes: specs.map((spec, index) => {
|
|
4004
|
+
const outcome = outcomes?.[index];
|
|
4005
|
+
return {
|
|
4006
|
+
laneId: spec.laneId,
|
|
4007
|
+
streamId: spec.streamId,
|
|
4008
|
+
personaId: spec.persona.id,
|
|
4009
|
+
...(outcome?.session === undefined ? {} : { session: outcome.session }),
|
|
4010
|
+
...(outcome?.session === undefined ? {} : { traceArtifactPath: spec.traceArtifactPath }),
|
|
4011
|
+
screenshots: outcome?.screenshots ?? [],
|
|
4012
|
+
...(outcome?.commsArtifactPath === undefined ? {} : { commsArtifactPath: outcome.commsArtifactPath })
|
|
4013
|
+
};
|
|
4014
|
+
})
|
|
4015
|
+
}),
|
|
3717
4016
|
// Custom desktop image provenance (every lane launched on it); omitted on the stock default.
|
|
3718
4017
|
...(config.execution?.desktop?.template === undefined ? {} : { desktopTemplate: config.execution.desktop.template }),
|
|
3719
4018
|
...(configuredBrowser === undefined
|