Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions capability/loadtesting.capability-index.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@
}
],
"intent": "List the caller's load-testing projects, most recently active first — use this to find a projectId or answer 'what projects do I have?'",
"cache": {
"ttlSec": 300
},
"guidance": [
"projectId from here is required by listLoadTests and createLoadTest.",
"Page with cursor + limit; nextCursor is opaque."
Expand Down Expand Up @@ -156,6 +159,9 @@
}
],
"intent": "List the load tests in a project — use this to find a testId or browse tests, optionally filtered by type, framework, tag or recent activity.",
"cache": {
"ttlSec": 300
},
"guidance": [
"Resolve projectId via listLoadTestProjects first.",
"The numeric testId returned here is what getLoadTest and startLoadTestRun need — not a runId.",
Expand Down Expand Up @@ -754,7 +760,8 @@
"Read-only despite being a POST — nothing is started.",
"fitsInQuota / remainingAfterEstimate compare the estimate against current quota.",
"For the same check at start time, call startLoadTestRun with dryRun:true.",
"Always use this for a cost or VU-hour estimate of a hypothetical run — it models ramp-up. Do not compute VU-hours by hand from VUs × duration."
"Always use this for a cost or VU-hour estimate of a hypothetical run — it models ramp-up. Do not compute VU-hours by hand from VUs × duration.",
"A protocol (plu) run is billed at a full load-generator pod's capacity (e.g. 1000 VUs for k6), not the VUs it actually uses, and carries a minimum billing floor (5 minutes) — so a small or short plu run can estimate far higher than VUs × duration would suggest. That is expected: report the returned estimatedVuHours as-is rather than second-guessing it as an error."
],
"returns": [
"estimatedVuHours",
Expand Down Expand Up @@ -941,7 +948,9 @@
"guidance": [
"Provide exactly one of projectId or projectName; projectName creates the project if absent.",
"Script upload is two-phase: send pendingScriptUpload to get a presigned uploadUrl + s3Key, PUT the file, then call create again with the scriptRef source and s3Key returned in the response's nextStep.",
"Pass idempotencyKey so a retried create does not duplicate the test."
"Pass idempotencyKey so a retried create does not duplicate the test.",
"testType and framework are required and are never inferred: testType is plu (protocol / API load), blu (real-browser load) or hybrid (both); framework is the load tool (k6, jmeter, gatling, locust). If the user has not stated them, ask — do not guess a default.",
"There is no clone capability. To duplicate a test, getLoadTest the source and copy its full config into this create — vuRamp/vus, durationSec, loadGeneratorLocations and slaThresholds included; nothing is inherited from the source, so anything you omit is dropped."
],
"returns": [
"testId",
Expand Down Expand Up @@ -1230,6 +1239,9 @@
}
],
"intent": "Discover which metrics, @ aliases and groupBy options a test supports — call this before requesting a report, trend or comparison so metric names are valid.",
"cache": {
"ttlSec": 900
},
"guidance": [
"Depends only on the test's type; cache the result client-side.",
"Use the returned names/aliases in the metrics parameter of report, trends and compare."
Expand Down Expand Up @@ -1310,7 +1322,8 @@
"guidance": [
"Partial update: send only the fields to change.",
"Pass ifVersion for optimistic concurrency; a stale value returns 409 VERSION_CONFLICT.",
"Script replacement uses the same two-phase pendingScriptUpload flow as create."
"Script replacement uses the same two-phase pendingScriptUpload flow as create.",
"config is a partial update, but each field it carries REPLACES that field wholesale — it does not merge. tags overwrites the entire tag set; it does not append. To add a tag to a test (or the same tag across several tests), getLoadTest each one first and send the union under config.tags."
],
"returns": [
"testId",
Expand Down Expand Up @@ -1368,6 +1381,9 @@
}
],
"intent": "Get the full configuration of a single load test — use this to inspect a test's settings, script reference, SLA thresholds and children.",
"cache": {
"ttlSec": 300
},
"guidance": [
"testId is numeric (from listLoadTests).",
"Use include to expand config / thresholds / tags / children.",
Expand Down
96 changes: 94 additions & 2 deletions src/tools/capability-registry/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
ResponseSelection,
resolveResponses,
} from "./index-loader.js";
import { invoke } from "./resolve.js";
import { invoke, InvokeResult } from "./resolve.js";
import { searchCapabilities } from "./search.js";
import { Mode } from "./types.js";

Expand Down Expand Up @@ -117,6 +117,23 @@ function ok(payload: unknown): CallToolResult {
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
}

/**
* A deterministic string for a cache key: object keys sorted at every level, so the same
* arguments in a different order produce the same key. Not a general serialiser — it handles
* exactly what grouped arguments are (nested objects, arrays, primitives).
*/
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value && typeof value === "object") {
const obj = value as Record<string, unknown>;
return `{${Object.keys(obj)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`)
.join(",")}}`;
}
return JSON.stringify(value ?? null);
}

function failed(message: string): CallToolResult {
return {
content: [
Expand Down Expand Up @@ -177,6 +194,37 @@ export function addCapabilityRegistryTools(
const transport = deps.transport || fetchTransport();
const tools: Record<string, RegisteredTool> = {};

// A response cache for reads a capability explicitly marks cacheable, so a task that asks
// for the same stable thing twice pays for one request, not two. It exists because the
// discover→invoke surface has no other memory: nothing else stops an agent re-fetching a
// test's configuration three times in one task.
//
// Scoped to the CALLING CREDENTIAL, not merely to this closure. The remote host may reuse
// one registration across sessions — the same reason `credentialsFor` is read per call and
// never captured — so a key that left the username out could serve one account's read to
// another. Only `mode:"read"` capabilities that declare `cache.ttlSec` are stored, and only
// a complete 2xx answer; a successful write to a product drops that product's entries for
// the writer. Bounded so a long-lived registration cannot grow without limit.
const READ_CACHE_MAX = 256;
const readCache = new Map<
string,
{ expiresAt: number; result: InvokeResult }
>();
const cacheKeyFor = (
username: string,
product: string,
method: string,
path: string,
args: GroupedArguments,
) =>
`${username}\n${product}\n${method.toUpperCase()} ${path}\n${stableStringify(args)}`;
const dropProductReads = (username: string, product: string) => {
const prefix = `${username}\n${product}\n`;
for (const key of readCache.keys()) {
if (key.startsWith(prefix)) readCache.delete(key);
}
};

/** Instrumentation in the house style, and never fatal to the call it wraps. */
const track = (name: string) => {
try {
Expand Down Expand Up @@ -464,14 +512,58 @@ export function addCapabilityRegistryTools(
}
}

const credentials = deps.credentialsFor();
const cacheable =
capability.mode === "read" &&
typeof capability.cache?.ttlSec === "number" &&
capability.cache.ttlSec > 0 &&
!!credentials?.username;
const cacheKey = cacheable
? cacheKeyFor(
credentials.username,
product,
input.method,
input.path,
args,
)
: null;

if (cacheKey) {
const hit = readCache.get(cacheKey);
if (hit && hit.expiresAt > Date.now()) {
// A repeat of a read already answered this task: the same body, flagged so the
// caller can see it did not need to ask again. The product is not touched.
return ok({ ...hit.result, cached: true });
}
if (hit) readCache.delete(cacheKey);
}

const result = await invoke(
capability,
args,
await deps.baseUrlFor(product),
deps.credentialsFor(),
credentials,
transport,
registry.index.products[product]?.auth,
);

if (capability.mode === "write" && result.ok && credentials?.username) {
// A successful write can change what a later read returns; drop this credential's
// cached reads for the product so the next read re-fetches.
dropProductReads(credentials.username, product);
}

if (cacheKey && result.ok && result.completed) {
if (readCache.size >= READ_CACHE_MAX) {
const oldest = readCache.keys().next().value;
if (oldest !== undefined) readCache.delete(oldest);
}
readCache.set(cacheKey, {
expiresAt: Date.now() + capability.cache!.ttlSec * 1000,
result,
});
}

return ok(result);
} catch (error) {
if (error instanceof InvocationError) return failed(error.message);
Expand Down
12 changes: 12 additions & 0 deletions src/tools/capability-registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ export interface Capability {
paginated?: boolean;
/** The largest page the operation declares. */
max_page_size?: number;
/**
* Opt-in response cache for a read whose answer is stable within a task.
*
* ABSENT MEANS NEVER CACHED — the default, and the only safe one for a volatile read (a
* run's live status, the active-run list, a run report). A capability declares this only
* when repeating it inside one task must return the same thing: a test's configuration or
* its metrics manifest, a project or test listing. Honoured solely for `mode: "read"`;
* ignored on a write. The stored answer is scoped to the calling credential and dropped
* when a write to the same product succeeds — the caching itself lives in `register.ts`,
* not here.
*/
cache?: { ttlSec: number };
/**
* Declared responses by status code, values possibly `{$response: "Name"}` references.
*
Expand Down
Loading