@workos/quickstudy 0.5.0 → 0.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/package.json +1 -1
- package/src/adapters/types.ts +10 -2
- package/src/build-info.generated.ts +1 -1
- package/src/evals/result.ts +1 -1
- package/src/evals/types.ts +9 -0
- package/src/execution-policy.ts +12 -0
- package/src/experiments/types.ts +1 -1
- package/src/report/report.ts +22 -8
- package/src/runner/execute.ts +6 -5
- package/src/runtime/types.ts +13 -0
- package/src/surface-usage.ts +246 -42
- package/ui/dist/index.html +8 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workos/quickstudy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "An open-source, company-agnostic eval harness for measuring whether coding agents can complete real integration tasks, across an agent x feature x framework x surface matrix.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/adapters/types.ts
CHANGED
|
@@ -60,7 +60,11 @@ export interface AttemptContext {
|
|
|
60
60
|
* it into the artifacts store afterwards.
|
|
61
61
|
*/
|
|
62
62
|
workspaceDir: string;
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* The prompt the agent receives: the eval's task body (byte-identical
|
|
65
|
+
* across experiments), preceded by the runtime's `promptPreamble` when the
|
|
66
|
+
* treatment declares one (recorded per pair in the manifest).
|
|
67
|
+
*/
|
|
64
68
|
prompt: string;
|
|
65
69
|
trialIndex: number;
|
|
66
70
|
}
|
|
@@ -112,7 +116,11 @@ export interface AgentAdapter {
|
|
|
112
116
|
export interface ContainerAttemptContext {
|
|
113
117
|
runId: string;
|
|
114
118
|
attemptId: string;
|
|
115
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* The prompt the agent receives: the eval's task body (byte-identical
|
|
121
|
+
* across experiments), preceded by the runtime's `promptPreamble` when the
|
|
122
|
+
* treatment declares one (recorded per pair in the manifest).
|
|
123
|
+
*/
|
|
116
124
|
prompt: string;
|
|
117
125
|
trialIndex: number;
|
|
118
126
|
/**
|
|
@@ -9,7 +9,7 @@ export const EMBEDDED_BUILD_INFO: {
|
|
|
9
9
|
// (the `x-release-please-version` marker anchors the replacement). It is
|
|
10
10
|
// restored as-is after a binary build (scripts/build-binary.ts), so the
|
|
11
11
|
// committed source `--version` stays in lockstep with package.json.
|
|
12
|
-
version: "0.
|
|
12
|
+
version: "0.6.0", // x-release-please-version
|
|
13
13
|
revision: null,
|
|
14
14
|
dirty: null,
|
|
15
15
|
sourceHash: null,
|
package/src/evals/result.ts
CHANGED
|
@@ -3,7 +3,7 @@ export function validEvalResult(value: unknown): value is EvalResult {
|
|
|
3
3
|
if (!value || typeof value !== "object") return false;
|
|
4
4
|
const result = value as EvalResult;
|
|
5
5
|
if (typeof result.passed !== "boolean" || !Array.isArray(result.checks)) return false;
|
|
6
|
-
if (result.checks.some((c) => !c || typeof c.name !== "string" || c.name === "" || typeof c.passed !== "boolean" || (c.graded !== undefined && typeof c.graded !== "boolean"))) return false;
|
|
6
|
+
if (result.checks.some((c) => !c || typeof c.name !== "string" || c.name === "" || typeof c.passed !== "boolean" || (c.graded !== undefined && typeof c.graded !== "boolean") || (c.kind !== undefined && (typeof c.kind !== "string" || c.kind === "")))) return false;
|
|
7
7
|
if (new Set(result.checks.map((c) => c.name)).size !== result.checks.length) return false;
|
|
8
8
|
return result.judgments === undefined || (Array.isArray(result.judgments) && result.judgments.every((j) => j && typeof j === "object" && j.provenance === "semantic-judge" &&
|
|
9
9
|
typeof j.provider === "string" && typeof j.model === "string" && [j.tokensIn, j.tokensOut, j.costUsd].every((v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0))));
|
package/src/evals/types.ts
CHANGED
|
@@ -56,6 +56,15 @@ export interface CheckResult {
|
|
|
56
56
|
* recall from it, and the scorer must leave it out of `passed`. Absent = true.
|
|
57
57
|
*/
|
|
58
58
|
graded?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* What a recorded check is about, so recorded checks of different natures
|
|
61
|
+
* never pool: `"sdk"` (did the agent use the product SDK) feeds SDK recall;
|
|
62
|
+
* any other tag (`"wiring"` for shape-of-code signals, …) is tallied and
|
|
63
|
+
* shown but never enters SDK recall. Absent on an ungraded check means
|
|
64
|
+
* `"sdk"` (the pre-tag convention). Free-form string; graded checks may
|
|
65
|
+
* carry one too, purely as a label.
|
|
66
|
+
*/
|
|
67
|
+
kind?: string;
|
|
59
68
|
notes?: string;
|
|
60
69
|
evidence?: EvidenceReference[];
|
|
61
70
|
}
|
package/src/execution-policy.ts
CHANGED
|
@@ -24,8 +24,16 @@ export interface PairEnvironment {
|
|
|
24
24
|
webPolicy: WebPolicy;
|
|
25
25
|
pathTreatment: string | null;
|
|
26
26
|
mcp: string | null;
|
|
27
|
+
/** Runtime text prepended to the eval prompt for this pair (null = prompt verbatim). */
|
|
28
|
+
promptPreamble: string | null;
|
|
27
29
|
budgets: LifecycleBudgets;
|
|
28
30
|
}
|
|
31
|
+
|
|
32
|
+
/** The prompt an agent receives: the runtime's preamble (if any), a blank line, the eval's verbatim body. */
|
|
33
|
+
export function composePrompt(preamble: string | null | undefined, body: string): string {
|
|
34
|
+
const text = preamble?.trim() ?? "";
|
|
35
|
+
return text === "" ? body : `${text}\n\n${body}`;
|
|
36
|
+
}
|
|
29
37
|
export function pairKey(evalId: string, experimentId: string): string {
|
|
30
38
|
return JSON.stringify([evalId, experimentId]);
|
|
31
39
|
}
|
|
@@ -64,6 +72,10 @@ export function resolvePairEnvironments(options: {
|
|
|
64
72
|
webPolicy: runtime.webPolicy?.() ?? "native-web-blocked",
|
|
65
73
|
pathTreatment: runtime.pathTreatment?.() ?? null,
|
|
66
74
|
mcp: options.mcpHashes?.[experiment.id] ?? null,
|
|
75
|
+
promptPreamble: (() => {
|
|
76
|
+
const text = runtime.promptPreamble?.(loaded.metadata)?.trim() ?? "";
|
|
77
|
+
return text === "" ? null : text;
|
|
78
|
+
})(),
|
|
67
79
|
budgets: { ...budgets },
|
|
68
80
|
};
|
|
69
81
|
}
|
package/src/experiments/types.ts
CHANGED
|
@@ -41,7 +41,7 @@ export interface Runtime extends RuntimeCapabilities {
|
|
|
41
41
|
* Images and lifecycle budgets can never be exempted. All group members must
|
|
42
42
|
* declare the same fields. Source/config identity still changes per attempt.
|
|
43
43
|
*/
|
|
44
|
-
treatmentPolicyFields?: Array<"egress" | "webPolicy" | "pathTreatment" | "mcp">;
|
|
44
|
+
treatmentPolicyFields?: Array<"egress" | "webPolicy" | "pathTreatment" | "mcp" | "promptPreamble">;
|
|
45
45
|
/** Container image tag (container runtimes; alternative to `containerImage`). */
|
|
46
46
|
image?: string;
|
|
47
47
|
/** Scripted command run in /workspace (container runtimes, this phase). */
|
package/src/report/report.ts
CHANGED
|
@@ -53,7 +53,7 @@ export class ReportError extends Error {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export const REPORT_SCHEMA_VERSION =
|
|
56
|
+
export const REPORT_SCHEMA_VERSION = 11;
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Substrings that must never appear in generated report prose: at n≤5 trials
|
|
@@ -82,7 +82,7 @@ export function methodologyNote(trials: number): string {
|
|
|
82
82
|
`those trials. Both describe this run's trials only — no wider claim. Telemetry (cost, tokens, surface ` +
|
|
83
83
|
`usage) is summed only over attempts that reported it and shown as unavailable — never zero — when none did. ` +
|
|
84
84
|
`SDK recall is reported per experiment as recalled / attempts, where attempts are the scored attempts that ` +
|
|
85
|
-
`recorded at least one ungraded check and recalled are those whose ungraded checks all passed. Ungraded ` +
|
|
85
|
+
`recorded at least one ungraded check of kind "sdk" (an ungraded check with no kind counts as "sdk") and recalled are those whose ungraded "sdk" checks all passed. Ungraded checks of any other kind are tallied and shown but never enter SDK recall. Ungraded ` +
|
|
86
86
|
`checks are recorded and tallied but never enter pass/fail. Scorer health lists checks that failed in every ` +
|
|
87
87
|
`scored attempt of an eval under every arm while another check in the same attempts passed: a statement ` +
|
|
88
88
|
`about the instrument, not about any treatment. Control experiments — an oracle that applies each eval's ` +
|
|
@@ -150,6 +150,12 @@ export interface ReportCheck {
|
|
|
150
150
|
* schema_version < 7 artifacts; readers treat absence as graded.
|
|
151
151
|
*/
|
|
152
152
|
graded: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* The `kind` the scorer recorded for this check (`"sdk"`, `"wiring"`, …),
|
|
155
|
+
* or null when no attempt tagged it. Absent in schema_version < 11
|
|
156
|
+
* artifacts; readers treat absence as null.
|
|
157
|
+
*/
|
|
158
|
+
kind: string | null;
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
/** One eval × experiment aggregation. Pairs with zero attempts are ABSENT, never 0%. */
|
|
@@ -313,7 +319,7 @@ export interface ReportJson {
|
|
|
313
319
|
const USAGE_VALUE_CAP = 20;
|
|
314
320
|
|
|
315
321
|
export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
|
|
316
|
-
const pairs = new Map<string, { pair: ReportPair; checks: Map<string, { passed: number; total: number; graded: boolean }>; outcomes: Set<boolean> }>();
|
|
322
|
+
const pairs = new Map<string, { pair: ReportPair; checks: Map<string, { passed: number; total: number; graded: boolean; kind: string | null }>; outcomes: Set<boolean> }>();
|
|
317
323
|
for (const attempt of attempts) {
|
|
318
324
|
const key = `${attempt.evalId}|${attempt.experimentId}`;
|
|
319
325
|
let entry = pairs.get(key);
|
|
@@ -350,10 +356,11 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
|
|
|
350
356
|
entry.outcomes.add(false);
|
|
351
357
|
}
|
|
352
358
|
for (const check of attempt.result?.checks ?? []) {
|
|
353
|
-
const tally = entry.checks.get(check.name) ?? { passed: 0, total: 0, graded: true };
|
|
359
|
+
const tally = entry.checks.get(check.name) ?? { passed: 0, total: 0, graded: true, kind: null };
|
|
354
360
|
tally.total += 1;
|
|
355
361
|
if (check.passed) tally.passed += 1;
|
|
356
362
|
if (check.graded === false) tally.graded = false;
|
|
363
|
+
if (tally.kind === null && typeof check.kind === "string" && check.kind !== "") tally.kind = check.kind;
|
|
357
364
|
entry.checks.set(check.name, tally);
|
|
358
365
|
}
|
|
359
366
|
}
|
|
@@ -365,7 +372,7 @@ export function collectPairs(attempts: AttemptRecord[]): ReportPair[] {
|
|
|
365
372
|
pair.pass_at_k = collectPassAtK(pair.passed, pair.scored);
|
|
366
373
|
pair.flaky = outcomes.size > 1;
|
|
367
374
|
pair.checks = [...checks.entries()]
|
|
368
|
-
.map(([name, tally]) => ({ name, passed: tally.passed, total: tally.total, graded: tally.graded }))
|
|
375
|
+
.map(([name, tally]) => ({ name, passed: tally.passed, total: tally.total, graded: tally.graded, kind: tally.kind }))
|
|
369
376
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
370
377
|
result.push(pair);
|
|
371
378
|
}
|
|
@@ -411,17 +418,24 @@ function treatmentOf(run: RunRecord, experimentId: string): string | null {
|
|
|
411
418
|
* experiment; "unspecified" is its own explicit missing-value sentinel, so
|
|
412
419
|
* this never invents a value the run did not pin.
|
|
413
420
|
*/
|
|
421
|
+
/** An ungraded check that feeds SDK recall: kind "sdk", or no kind (the pre-tag convention). */
|
|
422
|
+
export function isSdkRecallCheck(check: { graded?: boolean | undefined; kind?: string | undefined }): boolean {
|
|
423
|
+
return check.graded === false && (check.kind === undefined || check.kind === "sdk");
|
|
424
|
+
}
|
|
425
|
+
|
|
414
426
|
/**
|
|
415
427
|
* SDK recall over one experiment's scored attempts: the denominator is every
|
|
416
|
-
* completed attempt that recorded at least one `graded: false` check
|
|
417
|
-
* attempt is recalled when all of those checks
|
|
428
|
+
* completed attempt that recorded at least one `graded: false` check of kind
|
|
429
|
+
* "sdk" (or untagged); an attempt is recalled when all of those checks
|
|
430
|
+
* passed. Ungraded checks of any other kind (wiring, …) never enter. Null
|
|
431
|
+
* rate at zero.
|
|
418
432
|
*/
|
|
419
433
|
export function collectRecall(attempts: AttemptRecord[], experimentId: string): ReportRecall {
|
|
420
434
|
let total = 0;
|
|
421
435
|
let recalled = 0;
|
|
422
436
|
for (const attempt of attempts) {
|
|
423
437
|
if (attempt.experimentId !== experimentId || attempt.status !== "completed") continue;
|
|
424
|
-
const ungraded = (attempt.result?.checks ?? []).filter(
|
|
438
|
+
const ungraded = (attempt.result?.checks ?? []).filter(isSdkRecallCheck);
|
|
425
439
|
if (ungraded.length === 0) continue;
|
|
426
440
|
total += 1;
|
|
427
441
|
if (ungraded.every((check) => check.passed)) recalled += 1;
|
package/src/runner/execute.ts
CHANGED
|
@@ -64,7 +64,7 @@ import {
|
|
|
64
64
|
type McpServerConfig,
|
|
65
65
|
} from "../isolation/mcp.ts";
|
|
66
66
|
import { hashString } from "../hash.ts";
|
|
67
|
-
import { DEFAULT_BUDGETS, pairKey, resolvePairEnvironments, type LifecycleBudgets, type PairEnvironment } from "../execution-policy.ts";
|
|
67
|
+
import { DEFAULT_BUDGETS, pairKey, resolvePairEnvironments, type LifecycleBudgets, type PairEnvironment, composePrompt } from "../execution-policy.ts";
|
|
68
68
|
import { ulid } from "../ids.ts";
|
|
69
69
|
import { attemptIdentityHash, buildRunManifest, type RunManifest, type RuntimeImageIdentity } from "../manifest.ts";
|
|
70
70
|
import { isMovingModelAlias } from "../model-identity.ts";
|
|
@@ -562,7 +562,7 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
|
|
|
562
562
|
try {
|
|
563
563
|
disposition =
|
|
564
564
|
experiment.runtime.kind === "host"
|
|
565
|
-
? await runHostAttemptV3(options, hostAdapters, loadedEval, experiment, entry, { runId, attemptId, attemptTimeoutMs, persisted })
|
|
565
|
+
? await runHostAttemptV3(options, hostAdapters, loadedEval, experiment, entry, { runId, attemptId, attemptTimeoutMs, persisted, environment: manifest.pairs?.[pairKey(entry.evalId, entry.experimentId)] })
|
|
566
566
|
: await runContainerAttemptV3(
|
|
567
567
|
options,
|
|
568
568
|
loadedEval,
|
|
@@ -661,7 +661,7 @@ async function runHostAttemptV3(
|
|
|
661
661
|
attemptId,
|
|
662
662
|
fixtureDir: sourceDir,
|
|
663
663
|
workspaceDir: scratchDir,
|
|
664
|
-
prompt: loadedEval.promptBody,
|
|
664
|
+
prompt: composePrompt(args.environment?.promptPreamble, loadedEval.promptBody),
|
|
665
665
|
trialIndex: entry.trialIndex,
|
|
666
666
|
}));
|
|
667
667
|
} catch (err) {
|
|
@@ -868,13 +868,14 @@ async function runContainerAttemptV3(
|
|
|
868
868
|
const promptCtx: ContainerAttemptContext = {
|
|
869
869
|
runId,
|
|
870
870
|
attemptId,
|
|
871
|
-
|
|
871
|
+
// The eval body is byte-identical across experiments; a runtime's
|
|
872
|
+
// promptPreamble (recorded per pair in the manifest) may precede it.
|
|
873
|
+
prompt: composePrompt(environment?.promptPreamble, loadedEval.promptBody),
|
|
872
874
|
trialIndex: entry.trialIndex,
|
|
873
875
|
...(rewrittenServers !== undefined ? { mcpServers: rewrittenServers } : {}),
|
|
874
876
|
webPolicy: environment?.webPolicy ?? "native-web-blocked",
|
|
875
877
|
};
|
|
876
878
|
// Treatment config files (e.g. the MCP treatment's vendor config).
|
|
877
|
-
// The prompt itself is byte-identical across experiments.
|
|
878
879
|
await inStage("environment", async () => {
|
|
879
880
|
for (const [path, content] of Object.entries(agentAdapter.setupFiles(promptCtx))) {
|
|
880
881
|
await container.writeFile(path, content);
|
package/src/runtime/types.ts
CHANGED
|
@@ -84,6 +84,19 @@ export interface RuntimeCapabilities {
|
|
|
84
84
|
webPolicy?(): WebPolicy;
|
|
85
85
|
/** Directory prepended to the container PATH (the CLI treatment), if any. */
|
|
86
86
|
pathTreatment?(): string | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Text placed BEFORE the eval's prompt for every attempt under this
|
|
89
|
+
* runtime — the one sanctioned way to tell the agent what the treatment
|
|
90
|
+
* offers ("a `acme` CLI is on PATH; run `acme --help`"). An offered surface
|
|
91
|
+
* nobody mentions is rarely discovered, and an undiscovered surface
|
|
92
|
+
* measures nothing. Identity-bearing: the resolved text is recorded per
|
|
93
|
+
* pair in the run manifest (`pairs[].promptPreamble`), so group members
|
|
94
|
+
* that differ here must list `"promptPreamble"` in `treatmentPolicyFields`
|
|
95
|
+
* (and mirror the text under `config[treatment]`) or comparison is
|
|
96
|
+
* withheld. `undefined` or an empty string means the prompt is used
|
|
97
|
+
* verbatim.
|
|
98
|
+
*/
|
|
99
|
+
promptPreamble?(metadata: EvalMetadata): string | undefined;
|
|
87
100
|
/** Capabilities (`query`, `getClient`) exposed to the eval's scorer. */
|
|
88
101
|
scorerCapabilities?(ctx: ScorerCapabilityContext): ScorerCapabilities;
|
|
89
102
|
}
|
package/src/surface-usage.ts
CHANGED
|
@@ -3,10 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Availability is recorded in the experiment manifest; this module answers a
|
|
5
5
|
* different question: did the agent actually call an MCP tool, invoke the
|
|
6
|
-
* product CLI, or
|
|
7
|
-
* conservative and stores only redacted identifiers. Full shell
|
|
8
|
-
* URL query strings can contain credentials and must remain in
|
|
9
|
-
* raw transcript, never in aggregate telemetry.
|
|
6
|
+
* product CLI, or reach for the product documentation? Extraction is
|
|
7
|
+
* deliberately conservative and stores only redacted identifiers. Full shell
|
|
8
|
+
* commands and URL query strings can contain credentials and must remain in
|
|
9
|
+
* the protected raw transcript, never in aggregate telemetry.
|
|
10
|
+
*
|
|
11
|
+
* Docs evidence comes only from fetches (native web tools, shell fetchers
|
|
12
|
+
* such as curl) and from web searches that name the product. A docs URL
|
|
13
|
+
* inside a file the agent WROTE is not a page it opened and is never
|
|
14
|
+
* counted. `docs.count` is the number of docs events (every fetch and every
|
|
15
|
+
* product-naming search); `docs.urls` lists each fetched page once, so a
|
|
16
|
+
* count above the URL total comes from repeated fetches, searches, or both.
|
|
17
|
+
* A docs host may carry a path prefix (`acme.com/docs`) so API calls to the
|
|
18
|
+
* same domain do not count as reading.
|
|
10
19
|
*/
|
|
11
20
|
|
|
12
21
|
import { parseJsonlLines } from "./adapters/parse.ts";
|
|
@@ -20,8 +29,15 @@ export interface SurfaceUsage {
|
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
export interface SurfaceUsageConfig {
|
|
23
|
-
/** Exact host or parent domain
|
|
32
|
+
/** Exact host or parent domain (subdomains also match), optionally with a path prefix. */
|
|
24
33
|
docsHosts?: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* Words a web search must mention to count as a docs search. Optional:
|
|
36
|
+
* derived from the docs hosts' product labels when absent — set it when
|
|
37
|
+
* the docs live on a hosting domain (`acme.readthedocs.io`) or the product
|
|
38
|
+
* name is not in the hostname.
|
|
39
|
+
*/
|
|
40
|
+
searchTokens?: readonly string[];
|
|
25
41
|
/** Product CLI executable names, without arguments. */
|
|
26
42
|
cliCommands?: readonly string[];
|
|
27
43
|
/** MCP server names as configured for the attempt. */
|
|
@@ -57,6 +73,8 @@ function claudeInvocations(event: Record<string, unknown>): ToolInvocation[] {
|
|
|
57
73
|
...(typeof item["id"] === "string" ? { id: item["id"] } : {}),
|
|
58
74
|
name: item["name"],
|
|
59
75
|
input: item["input"],
|
|
76
|
+
...(/^web(fetch|search)$/i.test(item["name"]) ? { kind: "web" as const } : {}),
|
|
77
|
+
...(/^bash$/i.test(item["name"]) ? { kind: "command" as const } : {}),
|
|
60
78
|
}];
|
|
61
79
|
});
|
|
62
80
|
}
|
|
@@ -75,7 +93,10 @@ function codexInvocations(event: Record<string, unknown>): ToolInvocation[] {
|
|
|
75
93
|
return [{ ...identity, name: server === "" ? tool : `${server}__${tool}`, input: item["arguments"] ?? item["input"], kind: "mcp" }];
|
|
76
94
|
}
|
|
77
95
|
if (type === "web_search") {
|
|
78
|
-
|
|
96
|
+
// The started phase carries an empty query; only the completed item says
|
|
97
|
+
// what was searched. Keyed by the same id, so it still counts once.
|
|
98
|
+
if (event["type"] !== "item.completed") return [];
|
|
99
|
+
return [{ ...identity, name: "web_search", input: { query: item["query"], action: item["action"] }, kind: "web" }];
|
|
79
100
|
}
|
|
80
101
|
return [];
|
|
81
102
|
}
|
|
@@ -122,57 +143,227 @@ function allStrings(value: unknown, out: string[] = []): string[] {
|
|
|
122
143
|
|
|
123
144
|
const URL_PATTERN = /https?:\/\/[^\s"'<>)}\]]+/giu;
|
|
124
145
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
146
|
+
/**
|
|
147
|
+
* A configured documentation target: a host (subdomains match) with an
|
|
148
|
+
* optional path prefix — `docs.acme.test`, or `acme.com/docs` so that API
|
|
149
|
+
* traffic to `api.acme.com` is never mistaken for reading the docs.
|
|
150
|
+
*/
|
|
151
|
+
interface DocsTarget {
|
|
152
|
+
host: string;
|
|
153
|
+
pathPrefix: string | null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function docsTargets(hosts: readonly string[]): DocsTarget[] {
|
|
157
|
+
return hosts
|
|
158
|
+
.map((entry) => entry.trim().replace(/^https?:\/\//iu, "").replace(/^\.+/u, ""))
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.map((entry) => {
|
|
161
|
+
// Hostnames are case-insensitive; paths are not, so only the host is lowercased.
|
|
162
|
+
const slash = entry.indexOf("/");
|
|
163
|
+
const host = (slash === -1 ? entry : entry.slice(0, slash)).toLowerCase().replace(/\.+$/u, "");
|
|
164
|
+
const prefix = slash === -1 ? "" : entry.slice(slash).replace(/\/+$/u, "");
|
|
165
|
+
return { host, pathPrefix: prefix === "" ? null : prefix };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function matchesDocsTarget(url: URL, targets: readonly DocsTarget[]): boolean {
|
|
170
|
+
const host = url.hostname.toLowerCase();
|
|
171
|
+
return targets.some(
|
|
172
|
+
(target) =>
|
|
173
|
+
(host === target.host || host.endsWith(`.${target.host}`)) &&
|
|
174
|
+
(target.pathPrefix === null || url.pathname === target.pathPrefix || url.pathname.startsWith(`${target.pathPrefix}/`)),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Subdomain labels that name a kind of site, never the product. */
|
|
179
|
+
const SITE_LABELS = new Set(["www", "docs", "doc", "developer", "developers", "dev", "api", "app", "apps", "help", "support"]);
|
|
180
|
+
|
|
181
|
+
/** Second-level labels of multi-part public suffixes (`co.uk`, `com.au`, `ac.jp`). */
|
|
182
|
+
const SECOND_LEVEL_SUFFIXES = new Set(["co", "com", "org", "net", "ac", "gov", "edu", "ne", "or"]);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The product tokens a web-search query must mention to count as a docs
|
|
186
|
+
* search. Explicit `searchTokens` win; otherwise every host label that is
|
|
187
|
+
* neither a site label (`docs`, `www`), the TLD, nor a second-level suffix:
|
|
188
|
+
* `acme` from `docs.acme.com`, `acme.cloud` and `docs.acme.co.uk`; `acme`
|
|
189
|
+
* and `readthedocs` from `acme.readthedocs.io`. A TLD such as `cloud` is
|
|
190
|
+
* never a product name.
|
|
191
|
+
*/
|
|
192
|
+
function productTokens(targets: readonly DocsTarget[], explicit: readonly string[]): string[] {
|
|
193
|
+
const configured = explicit.map((token) => token.trim().toLowerCase()).filter((token) => token !== "");
|
|
194
|
+
if (configured.length > 0) return [...new Set(configured)];
|
|
195
|
+
const tokens = new Set<string>();
|
|
196
|
+
for (const target of targets) {
|
|
197
|
+
const labels = target.host.split(".").filter(Boolean);
|
|
198
|
+
while (labels.length > 1 && SITE_LABELS.has(labels[0]!)) labels.shift();
|
|
199
|
+
if (labels.length >= 2) labels.pop(); // the TLD
|
|
200
|
+
if (labels.length >= 2 && SECOND_LEVEL_SUFFIXES.has(labels[labels.length - 1]!)) labels.pop();
|
|
201
|
+
for (const label of labels) if (label.length >= 3) tokens.add(label);
|
|
202
|
+
}
|
|
203
|
+
return [...tokens];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Programs whose arguments are pages the agent opened. */
|
|
207
|
+
const FETCHERS = new Set(["curl", "wget", "xh", "http", "https", "httpie", "aria2c", "lynx", "w3m"]);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* URLs a shell command actually fetched: the arguments of a fetching program
|
|
211
|
+
* that is the leading executable of its pipeline segment. `echo "curl …"`,
|
|
212
|
+
* a heredoc body, or a file written through the shell is not a page the
|
|
213
|
+
* agent opened (heredoc bodies are dropped by shellSegments).
|
|
214
|
+
*/
|
|
215
|
+
function fetchedUrlsInCommand(command: string): string[] {
|
|
128
216
|
const urls: string[] = [];
|
|
129
|
-
for (const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (!configured.some((allowed) => host === allowed || host.endsWith(`.${allowed}`))) continue;
|
|
135
|
-
url.username = "";
|
|
136
|
-
url.password = "";
|
|
137
|
-
url.search = "";
|
|
138
|
-
url.hash = "";
|
|
139
|
-
const safe = url.toString();
|
|
140
|
-
if (!urls.includes(safe)) urls.push(safe);
|
|
141
|
-
} catch {
|
|
142
|
-
// A transcript fragment that merely resembles a URL is not evidence.
|
|
143
|
-
}
|
|
217
|
+
for (const segment of shellSegments(command)) {
|
|
218
|
+
const lead = leadingExecutable(segment);
|
|
219
|
+
if (lead === null || !FETCHERS.has(lead.name)) continue;
|
|
220
|
+
for (const token of lead.rest) {
|
|
221
|
+
for (const url of token.replace(/^['"]|['"]$/g, "").match(URL_PATTERN) ?? []) urls.push(url);
|
|
144
222
|
}
|
|
145
223
|
}
|
|
146
224
|
return urls;
|
|
147
225
|
}
|
|
148
226
|
|
|
227
|
+
function sanitizeUrl(raw: string, targets: readonly DocsTarget[]): string | null {
|
|
228
|
+
try {
|
|
229
|
+
const url = new URL(raw);
|
|
230
|
+
if (!matchesDocsTarget(url, targets)) return null;
|
|
231
|
+
url.username = "";
|
|
232
|
+
url.password = "";
|
|
233
|
+
url.search = "";
|
|
234
|
+
url.hash = "";
|
|
235
|
+
return url.toString();
|
|
236
|
+
} catch {
|
|
237
|
+
// A transcript fragment that merely resembles a URL is not evidence.
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The vendors' native web tools, by exact name: a search is an event, a fetch is a page. */
|
|
243
|
+
const WEB_SEARCH_TOOL = /^(?:web_?search|google_web_search)$/i;
|
|
244
|
+
const WEB_FETCH_TOOL = /^(?:web_?fetch)$/i;
|
|
245
|
+
|
|
246
|
+
/** The page a fetch tool was asked for: its URL field, never a URL mentioned in a prompt or query. */
|
|
247
|
+
function fetchUrlOf(input: unknown): string | null {
|
|
248
|
+
const fields = record(input);
|
|
249
|
+
for (const key of ["url", "URL", "uri", "href"]) {
|
|
250
|
+
const value = fields[key];
|
|
251
|
+
if (typeof value === "string" && value !== "") return value;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A search tool's query text (Claude `query`, Codex `query`/`action.query`), for the product-token test. */
|
|
257
|
+
function searchQueryOf(input: unknown): string {
|
|
258
|
+
const fields = record(input);
|
|
259
|
+
const action = record(fields["action"]);
|
|
260
|
+
return [fields["query"], action["query"], ...(Array.isArray(action["queries"]) ? action["queries"] : [])]
|
|
261
|
+
.filter((value): value is string => typeof value === "string")
|
|
262
|
+
.join(" ");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Documentation evidence from ONE invocation: the page a web-fetch tool or
|
|
267
|
+
* a shell fetcher opened, or a web search naming the product (a docs event,
|
|
268
|
+
* never a URL: a query is not a page). MCP tools never count here — an
|
|
269
|
+
* MCP `search_docs` is MCP use, tallied on its own channel. A URL that only
|
|
270
|
+
* appears in a prompt, a query, or a file the agent wrote is not evidence.
|
|
271
|
+
*/
|
|
272
|
+
function docsEvidence(
|
|
273
|
+
invocation: ToolInvocation,
|
|
274
|
+
targets: readonly DocsTarget[],
|
|
275
|
+
searchTokens: readonly string[],
|
|
276
|
+
): { urls: string[]; search: boolean } {
|
|
277
|
+
const none = { urls: [] as string[], search: false };
|
|
278
|
+
if (targets.length === 0 || invocation.kind === "mcp" || /^mcp__/i.test(invocation.name)) return none;
|
|
279
|
+
const urls: string[] = [];
|
|
280
|
+
const push = (raw: string): void => {
|
|
281
|
+
const safe = sanitizeUrl(raw, targets);
|
|
282
|
+
if (safe !== null && !urls.includes(safe)) urls.push(safe);
|
|
283
|
+
};
|
|
284
|
+
if (WEB_FETCH_TOOL.test(invocation.name)) {
|
|
285
|
+
const url = fetchUrlOf(invocation.input);
|
|
286
|
+
if (url !== null) push(url);
|
|
287
|
+
return { urls, search: false };
|
|
288
|
+
}
|
|
289
|
+
if (WEB_SEARCH_TOOL.test(invocation.name)) {
|
|
290
|
+
// Codex reports an opened page as a search item with `action.url`.
|
|
291
|
+
const opened = fetchUrlOf(record(invocation.input)["action"]);
|
|
292
|
+
if (opened !== null) {
|
|
293
|
+
push(opened);
|
|
294
|
+
return { urls, search: false };
|
|
295
|
+
}
|
|
296
|
+
const query = searchQueryOf(invocation.input).toLowerCase();
|
|
297
|
+
const tokens = productTokens(targets, searchTokens);
|
|
298
|
+
return { urls, search: query.trim() !== "" && tokens.some((token) => query.includes(token)) };
|
|
299
|
+
}
|
|
300
|
+
if (invocation.kind === "command" || /bash|shell|terminal|command/i.test(invocation.name)) {
|
|
301
|
+
const command = typeof invocation.input === "string" ? invocation.input : allStrings(invocation.input).join("\n");
|
|
302
|
+
for (const raw of fetchedUrlsInCommand(command)) push(raw);
|
|
303
|
+
}
|
|
304
|
+
return { urls, search: false };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Heredoc bodies (`cat <<EOF … EOF`) are file contents, not commands. Only
|
|
309
|
+
* the body goes; the opener line keeps whatever follows the redirection
|
|
310
|
+
* (`cat <<EOF > README.md && acme --help` still runs `acme --help`).
|
|
311
|
+
*/
|
|
312
|
+
const HEREDOC = /(<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2[^\n]*)\n[\s\S]*?\n[ \t]*\3[ \t]*(?=\n|$)/gu;
|
|
313
|
+
|
|
314
|
+
/** A backslash-newline continues the same command. */
|
|
315
|
+
const LINE_CONTINUATION = /\\\n/gu;
|
|
316
|
+
|
|
149
317
|
function shellSegments(command: string): string[] {
|
|
150
318
|
// Unwrap the common structured-stream representation `bash -lc '<body>'`.
|
|
151
319
|
// This is intentionally not a full shell parser; it only finds the leading
|
|
152
320
|
// executable of each simple pipeline/list segment and never executes text.
|
|
153
321
|
const wrapped = /^\s*(?:\/bin\/)?(?:ba|z|da)?sh\s+-[a-z]*c\s+(['"])([\s\S]*)\1\s*$/u.exec(command);
|
|
154
|
-
const body = wrapped?.[2] ?? command;
|
|
322
|
+
const body = (wrapped?.[2] ?? command).replace(LINE_CONTINUATION, " ").replace(HEREDOC, "$1");
|
|
155
323
|
return body.split(/(?:&&|\|\||[;|\n])/u).map((segment) => segment.trim().replace(/^['"(]+|['")]+$/g, ""));
|
|
156
324
|
}
|
|
157
325
|
|
|
326
|
+
/** Words that precede the real executable of a segment without changing what runs. */
|
|
327
|
+
const COMMAND_PREFIXES = new Set([
|
|
328
|
+
"sudo", "env", "command", "exec", "nohup", "time", "timeout", "nice", "xargs",
|
|
329
|
+
"if", "elif", "while", "until", "then", "do", "else", "!", "{", "(",
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The executable a segment runs (after prefixes such as `sudo`, `command`,
|
|
334
|
+
* `if`, and VAR=value) and its arguments. `command -v x` / `-V x` only look
|
|
335
|
+
* `x` up and run nothing, so they yield no executable.
|
|
336
|
+
*/
|
|
337
|
+
function leadingExecutable(segment: string): { name: string; rest: string[] } | null {
|
|
338
|
+
const tokens = segment.split(/\s+/u).filter(Boolean);
|
|
339
|
+
let index = 0;
|
|
340
|
+
while (
|
|
341
|
+
index < tokens.length &&
|
|
342
|
+
(COMMAND_PREFIXES.has(tokens[index]!) ||
|
|
343
|
+
/^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(tokens[index]!) ||
|
|
344
|
+
(tokens[index]!.startsWith("-") && index > 0 && COMMAND_PREFIXES.has(tokens[index - 1]!)))
|
|
345
|
+
) {
|
|
346
|
+
// `command -v x` / `-V x` (after any prefix: `if command -v x`) looks x up and runs nothing.
|
|
347
|
+
if (tokens[index] === "command" && (tokens[index + 1] === "-v" || tokens[index + 1] === "-V")) return null;
|
|
348
|
+
index += 1;
|
|
349
|
+
}
|
|
350
|
+
const invoked = (tokens[index] ?? "").replace(/^['"]|['"]$/g, "");
|
|
351
|
+
if (invoked === "") return null;
|
|
352
|
+
return { name: invoked.split("/").at(-1) ?? invoked, rest: tokens.slice(index + 1) };
|
|
353
|
+
}
|
|
354
|
+
|
|
158
355
|
/** Return a non-secret command label such as `product widgets`, or null. */
|
|
159
356
|
function cliLabel(input: unknown, executables: readonly string[]): string | null {
|
|
160
357
|
const command = typeof input === "string" ? input : allStrings(input).join("\n");
|
|
161
358
|
for (const segment of shellSegments(command)) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const tokens = segment.split(/\s+/u).filter(Boolean);
|
|
165
|
-
let index = 0;
|
|
166
|
-
while (tokens[index] === "sudo" || tokens[index] === "env" || /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(tokens[index] ?? "")) {
|
|
167
|
-
index += 1;
|
|
168
|
-
}
|
|
169
|
-
const invoked = (tokens[index] ?? "").replace(/^['"]|['"]$/g, "");
|
|
359
|
+
const lead = leadingExecutable(segment);
|
|
360
|
+
if (lead === null) continue;
|
|
170
361
|
for (const executable of executables) {
|
|
171
362
|
const trimmed = executable.trim();
|
|
172
|
-
if (trimmed === "") continue;
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
363
|
+
if (trimmed === "" || lead.name !== trimmed) continue;
|
|
364
|
+
const rawSubcommand = lead.rest[0]?.replace(/^['"]|['"]$/g, "");
|
|
365
|
+
// `acme = Client()` is a line of source, not a CLI call.
|
|
366
|
+
if (rawSubcommand !== undefined && /^[=:]/u.test(rawSubcommand)) continue;
|
|
176
367
|
const subcommand = rawSubcommand && !rawSubcommand.startsWith("-") ? rawSubcommand : undefined;
|
|
177
368
|
return subcommand ? `${trimmed} ${subcommand}` : trimmed;
|
|
178
369
|
}
|
|
@@ -218,10 +409,13 @@ export function surfaceUsageConfigFromRuntime(
|
|
|
218
409
|
): SurfaceUsageConfig {
|
|
219
410
|
const docsHosts: string[] = [];
|
|
220
411
|
const cliCommands: string[] = [];
|
|
412
|
+
const searchTokens: string[] = [];
|
|
221
413
|
const visit = (value: unknown): void => {
|
|
222
414
|
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
223
415
|
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
224
|
-
if (
|
|
416
|
+
if (key === "searchTokens" && Array.isArray(item)) {
|
|
417
|
+
for (const token of item) if (typeof token === "string") searchTokens.push(token);
|
|
418
|
+
} else if ((key === "hosts" || key === "docsHosts") && Array.isArray(item)) {
|
|
225
419
|
// Wildcard entries ("*.example.com") reduce to their parent domain —
|
|
226
420
|
// the matcher already covers subdomains of a configured parent.
|
|
227
421
|
for (const host of item) if (typeof host === "string") docsHosts.push(host.replace(/^\*\./u, ""));
|
|
@@ -237,6 +431,7 @@ export function surfaceUsageConfigFromRuntime(
|
|
|
237
431
|
docsHosts: [...new Set(docsHosts)],
|
|
238
432
|
cliCommands: [...new Set(cliCommands)],
|
|
239
433
|
mcpServers: [...new Set(mcpServerNames)],
|
|
434
|
+
...(searchTokens.length > 0 ? { searchTokens: [...new Set(searchTokens)] } : {}),
|
|
240
435
|
};
|
|
241
436
|
}
|
|
242
437
|
|
|
@@ -257,7 +452,7 @@ export function extractSurfaceUsage(
|
|
|
257
452
|
mcp: { count: 0, tools: [] },
|
|
258
453
|
cli: { count: 0, commands: [] },
|
|
259
454
|
};
|
|
260
|
-
const
|
|
455
|
+
const targets = docsTargets(config.docsHosts ?? []);
|
|
261
456
|
const cliCommands = config.cliCommands ?? [];
|
|
262
457
|
const mcpServers = config.mcpServers ?? [];
|
|
263
458
|
const seenInvocations = new Set<string>();
|
|
@@ -278,9 +473,9 @@ export function extractSurfaceUsage(
|
|
|
278
473
|
: null;
|
|
279
474
|
if (command) pushUnique(usage.cli, usage.cli.commands, command);
|
|
280
475
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
476
|
+
const docs = docsEvidence(invocation, targets, config.searchTokens ?? []);
|
|
477
|
+
for (const url of docs.urls) pushUnique(usage.docs, usage.docs.urls, url);
|
|
478
|
+
if (docs.search) usage.docs.count += 1;
|
|
284
479
|
}
|
|
285
480
|
}
|
|
286
481
|
|
|
@@ -288,10 +483,19 @@ export function extractSurfaceUsage(
|
|
|
288
483
|
}
|
|
289
484
|
|
|
290
485
|
/** Observe offered surfaces plus explicitly shared targets, without changing offered state. */
|
|
486
|
+
/** The words a web search must mention for THIS config: its explicit tokens, else those derived from its own docs hosts. */
|
|
487
|
+
export function resolveSearchTokens(config: SurfaceUsageConfig): string[] {
|
|
488
|
+
return productTokens(docsTargets(config.docsHosts ?? []), config.searchTokens ?? []);
|
|
489
|
+
}
|
|
490
|
+
|
|
291
491
|
export function observationConfig(offered: SurfaceUsageConfig, targets?: SurfaceUsageConfig): SurfaceUsageConfig {
|
|
492
|
+
// Resolve each side on its own first: explicit tokens for one target must
|
|
493
|
+
// never replace the words derived for another target's hosts.
|
|
494
|
+
const searchTokens = [...new Set([...resolveSearchTokens(offered), ...(targets ? resolveSearchTokens(targets) : [])])].sort();
|
|
292
495
|
return {
|
|
293
496
|
docsHosts: [...new Set([...(offered.docsHosts ?? []), ...(targets?.docsHosts ?? [])])].sort(),
|
|
294
497
|
cliCommands: [...new Set([...(offered.cliCommands ?? []), ...(targets?.cliCommands ?? [])])].sort(),
|
|
295
498
|
mcpServers: [...new Set([...(offered.mcpServers ?? []), ...(targets?.mcpServers ?? [])])].sort(),
|
|
499
|
+
...(searchTokens.length > 0 ? { searchTokens } : {}),
|
|
296
500
|
};
|
|
297
501
|
}
|