@langwatch/mcp-server 0.9.0 → 0.10.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/CHANGELOG.md +19 -0
- package/dist/{chunk-JVLJKQJG.js → chunk-FVDAZ4XG.js} +123 -19
- package/dist/create-mcp-server.d.ts +3 -1
- package/dist/create-mcp-server.js +1 -1
- package/dist/{http-server-DEGNV5EP.js → http-server-XUQLIFE7.js} +4 -3
- package/dist/index.js +132 -19
- package/package.json +1 -1
- package/pnpm-workspace.yaml +5 -0
- package/src/__tests__/experiment-status.unit.test.ts +194 -0
- package/src/__tests__/get-experiment-results.unit.test.ts +96 -5
- package/src/create-mcp-server.d.ts +3 -1
- package/src/create-mcp-server.ts +8 -2
- package/src/http-server.ts +5 -0
- package/src/tools/experiment-run-status.ts +36 -0
- package/src/tools/get-experiment-results.ts +40 -7
- package/src/tools/run-experiment.ts +100 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.10.0](https://github.com/langwatch/langwatch/compare/mcp-server@v0.9.0...mcp-server@v0.10.0) (2026-05-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **governance:** the LangWatch AI Governance Platform ([#3524](https://github.com/langwatch/langwatch/issues/3524)) ([7dcaa89](https://github.com/langwatch/langwatch/commit/7dcaa895e29f6f2cce8d1e9160ef857520af340a))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **deps:** move mcp-server pnpm.overrides to workspace root ([#4187](https://github.com/langwatch/langwatch/issues/4187)) ([89e45ce](https://github.com/langwatch/langwatch/commit/89e45cefa3c16ef1c11e1d74c24219b182c0d716))
|
|
14
|
+
* **mcp:** serve partial experiment results and fix status for SDK runs ([#4276](https://github.com/langwatch/langwatch/issues/4276)) ([d21997a](https://github.com/langwatch/langwatch/commit/d21997a3c65146b0f52026dccf0907e2abeb8bb9))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Miscellaneous
|
|
18
|
+
|
|
19
|
+
* **deps:** bump liquidjs to >=10.26.0 (RCE GHSA-gf2q-c269-pqgc) in typescript-sdk, skills, mcp-server ([#4340](https://github.com/langwatch/langwatch/issues/4340)) ([697e07e](https://github.com/langwatch/langwatch/commit/697e07e82241dc837afb414458e67f539d23fb54))
|
|
20
|
+
* **deps:** bump liquidjs to >=10.26.0 in typescript-sdk, skills, mcp-server (GHSA-gf2q-c269-pqgc) ([697e07e](https://github.com/langwatch/langwatch/commit/697e07e82241dc837afb414458e67f539d23fb54))
|
|
21
|
+
|
|
3
22
|
## [0.9.0](https://github.com/langwatch/langwatch/compare/mcp-server@v0.8.0...mcp-server@v0.9.0) (2026-05-22)
|
|
4
23
|
|
|
5
24
|
|
|
@@ -13,7 +13,7 @@ import { z as z2 } from "zod";
|
|
|
13
13
|
// package.json
|
|
14
14
|
var package_default = {
|
|
15
15
|
name: "@langwatch/mcp-server",
|
|
16
|
-
version: "0.
|
|
16
|
+
version: "0.10.0",
|
|
17
17
|
description: "An MCP server for Langwatch.",
|
|
18
18
|
type: "module",
|
|
19
19
|
main: "./dist/index.js",
|
|
@@ -91,9 +91,7 @@ var package_default = {
|
|
|
91
91
|
},
|
|
92
92
|
pnpm: {
|
|
93
93
|
overrides: {
|
|
94
|
-
|
|
95
|
-
"langsmith@<0.6.0": "0.6.0",
|
|
96
|
-
"fast-uri@<3.1.2": "3.1.2"
|
|
94
|
+
liquidjs: ">=10.26.0"
|
|
97
95
|
}
|
|
98
96
|
}
|
|
99
97
|
};
|
|
@@ -124,6 +122,18 @@ var createDatasetSchema = z.object({
|
|
|
124
122
|
columnTypes: z.array(datasetColumnDefinitionSchema).optional().describe("Column definitions for the dataset")
|
|
125
123
|
});
|
|
126
124
|
|
|
125
|
+
// src/tools/experiment-run-status.ts
|
|
126
|
+
var INTERRUPTED_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
127
|
+
var deriveRunStatus = (timestamps, now = Date.now()) => {
|
|
128
|
+
if (timestamps.stoppedAt != null) return "stopped";
|
|
129
|
+
if (timestamps.finishedAt != null) return "completed";
|
|
130
|
+
if (timestamps.updatedAt != null && now - timestamps.updatedAt > INTERRUPTED_THRESHOLD_MS) {
|
|
131
|
+
return "interrupted";
|
|
132
|
+
}
|
|
133
|
+
return "running";
|
|
134
|
+
};
|
|
135
|
+
var isTerminalStatus = (status) => status === "completed" || status === "stopped";
|
|
136
|
+
|
|
127
137
|
// src/tools/get-experiment-results.ts
|
|
128
138
|
var DEFAULT_ROW_CAP = 50;
|
|
129
139
|
var rowKey = (index, targetId) => `${index}:${targetId ?? ""}`;
|
|
@@ -164,24 +174,26 @@ async function handleExperimentResults(params) {
|
|
|
164
174
|
"",
|
|
165
175
|
"**Status**: not found",
|
|
166
176
|
"",
|
|
167
|
-
`Could not load results for run \`${params.runId}\`. The run may
|
|
177
|
+
`Could not load results for run \`${params.runId}\`. The run id may be incorrect, or its run state may have expired (Redis keeps it for 24h).`,
|
|
168
178
|
"",
|
|
169
|
-
">
|
|
179
|
+
"> Pass `experimentSlug` to load results for runs older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` for the run ids."
|
|
170
180
|
].join("\n");
|
|
171
181
|
}
|
|
172
182
|
throw error;
|
|
173
183
|
}
|
|
174
|
-
if (!results
|
|
184
|
+
if (!results) {
|
|
175
185
|
return [
|
|
176
186
|
`# Evaluation Results: ${params.runId}`,
|
|
177
187
|
"",
|
|
178
|
-
"**Status**:
|
|
188
|
+
"**Status**: not found",
|
|
179
189
|
"",
|
|
180
|
-
`
|
|
190
|
+
`Could not load results for run \`${params.runId}\`.`,
|
|
181
191
|
"",
|
|
182
|
-
">
|
|
192
|
+
"> Pass `experimentSlug` if the run is older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` to confirm the run id."
|
|
183
193
|
].join("\n");
|
|
184
194
|
}
|
|
195
|
+
const runStatus2 = deriveRunStatus(results.timestamps);
|
|
196
|
+
const partial = !isTerminalStatus(runStatus2);
|
|
185
197
|
const evaluationsByRow = /* @__PURE__ */ new Map();
|
|
186
198
|
for (const evaluation of results.evaluations) {
|
|
187
199
|
if (evaluatorFilter && evaluation.evaluator !== evaluatorFilter) continue;
|
|
@@ -232,6 +244,12 @@ async function handleExperimentResults(params) {
|
|
|
232
244
|
lines.push(`# Evaluation Results: ${results.runId}`);
|
|
233
245
|
lines.push("");
|
|
234
246
|
lines.push(`**Experiment**: ${results.experimentId}`);
|
|
247
|
+
lines.push(`**Status**: ${runStatus2}`);
|
|
248
|
+
if (typeof results.total === "number" && results.total > 0) {
|
|
249
|
+
lines.push(
|
|
250
|
+
`**Progress**: ${results.progress ?? results.dataset.length}/${results.total} rows`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
235
253
|
lines.push(`**Total rows**: ${results.dataset.length}`);
|
|
236
254
|
lines.push(`**Total evaluations**: ${results.evaluations.length}`);
|
|
237
255
|
if (filter === "failed") {
|
|
@@ -240,6 +258,12 @@ async function handleExperimentResults(params) {
|
|
|
240
258
|
if (evaluatorFilter) {
|
|
241
259
|
lines.push(`**Evaluator filter**: ${evaluatorFilter}`);
|
|
242
260
|
}
|
|
261
|
+
if (partial) {
|
|
262
|
+
lines.push("");
|
|
263
|
+
lines.push(
|
|
264
|
+
runStatus2 === "interrupted" ? "> These are partial results. The run never sent a finished/stopped marker and has had no updates recently, so it likely was interrupted before completing. The rows below are everything recorded so far." : "> These are partial results. The run is still in progress, so more rows may appear on a later call."
|
|
265
|
+
);
|
|
266
|
+
}
|
|
243
267
|
lines.push("");
|
|
244
268
|
if (evaluatorAverages.size > 0) {
|
|
245
269
|
lines.push("## Evaluator Summary");
|
|
@@ -255,7 +279,17 @@ async function handleExperimentResults(params) {
|
|
|
255
279
|
lines.push("");
|
|
256
280
|
}
|
|
257
281
|
if (rows.length === 0) {
|
|
258
|
-
|
|
282
|
+
if (filter === "failed") {
|
|
283
|
+
lines.push("_No rows matched the filter._");
|
|
284
|
+
} else if (runStatus2 === "running") {
|
|
285
|
+
lines.push(
|
|
286
|
+
"_No rows recorded yet. The run is still in progress; call again shortly._"
|
|
287
|
+
);
|
|
288
|
+
} else if (runStatus2 === "interrupted") {
|
|
289
|
+
lines.push("_No rows were recorded before the run was interrupted._");
|
|
290
|
+
} else {
|
|
291
|
+
lines.push("_No rows recorded for this run._");
|
|
292
|
+
}
|
|
259
293
|
return lines.join("\n");
|
|
260
294
|
}
|
|
261
295
|
lines.push(
|
|
@@ -461,11 +495,78 @@ async function handleRunExperiment(params) {
|
|
|
461
495
|
);
|
|
462
496
|
return lines.join("\n");
|
|
463
497
|
}
|
|
464
|
-
async function
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
498
|
+
async function statusFromResults(params) {
|
|
499
|
+
const search = new URLSearchParams();
|
|
500
|
+
if (params.experimentSlug) search.set("experimentSlug", params.experimentSlug);
|
|
501
|
+
const qs = search.toString() ? `?${search.toString()}` : "";
|
|
502
|
+
let results;
|
|
503
|
+
try {
|
|
504
|
+
results = await makeRequest(
|
|
505
|
+
"GET",
|
|
506
|
+
`/api/experiments/runs/${encodeURIComponent(params.runId)}/results${qs}`
|
|
507
|
+
);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
const code = error instanceof LangWatchApiError ? error.status : void 0;
|
|
510
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
511
|
+
if (code === 404 || code === void 0 && /404|not found/i.test(message)) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
const status = deriveRunStatus(results.timestamps);
|
|
517
|
+
const total = results.total ?? results.dataset?.length ?? 0;
|
|
518
|
+
const progress = results.progress ?? results.dataset?.length ?? 0;
|
|
519
|
+
const lines = [];
|
|
520
|
+
lines.push(`# Evaluation Run ${params.runId}
|
|
521
|
+
`);
|
|
522
|
+
lines.push(`**Status**: ${status}`);
|
|
523
|
+
lines.push(`**Progress**: ${progress}/${total} cells`);
|
|
524
|
+
if (results.timestamps.createdAt) {
|
|
525
|
+
lines.push(
|
|
526
|
+
`**Started**: ${new Date(results.timestamps.createdAt).toISOString()}`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (results.timestamps.finishedAt) {
|
|
530
|
+
lines.push(
|
|
531
|
+
`**Finished**: ${new Date(results.timestamps.finishedAt).toISOString()}`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (results.timestamps.stoppedAt) {
|
|
535
|
+
lines.push(
|
|
536
|
+
`**Stopped**: ${new Date(results.timestamps.stoppedAt).toISOString()}`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
lines.push("");
|
|
540
|
+
lines.push(
|
|
541
|
+
"> Use `platform_experiment_results` to fetch the per-row scores (partial results are available even while running)."
|
|
468
542
|
);
|
|
543
|
+
return lines.join("\n");
|
|
544
|
+
}
|
|
545
|
+
async function handleExperimentStatus(params) {
|
|
546
|
+
let status;
|
|
547
|
+
try {
|
|
548
|
+
status = await makeRequest(
|
|
549
|
+
"GET",
|
|
550
|
+
`/api/experiments/runs/${encodeURIComponent(params.runId)}`
|
|
551
|
+
);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
const code = error instanceof LangWatchApiError ? error.status : void 0;
|
|
554
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
555
|
+
if (code === 404 || /404|not found/i.test(message)) {
|
|
556
|
+
const fallback = await statusFromResults(params);
|
|
557
|
+
if (fallback) return fallback;
|
|
558
|
+
return [
|
|
559
|
+
`# Evaluation Run ${params.runId}`,
|
|
560
|
+
"",
|
|
561
|
+
"**Status**: not found",
|
|
562
|
+
"",
|
|
563
|
+
`Could not find run \`${params.runId}\`. SDK-logged runs and runs older than 24h are not in the live run-state and must be resolved by experiment slug.`,
|
|
564
|
+
"",
|
|
565
|
+
"> Pass `experimentSlug`: discover it with `platform_experiment_list`, then use `platform_experiment_list_runs` for the run ids. Or fetch the rows directly with `platform_experiment_results`."
|
|
566
|
+
].join("\n");
|
|
567
|
+
}
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
469
570
|
const lines = [];
|
|
470
571
|
lines.push(`# Evaluation Run ${status.runId}
|
|
471
572
|
`);
|
|
@@ -1775,9 +1876,12 @@ ${lines.join("\n")}`
|
|
|
1775
1876
|
);
|
|
1776
1877
|
server.tool(
|
|
1777
1878
|
"platform_experiment_status",
|
|
1778
|
-
"Check the status of an experiment run
|
|
1879
|
+
"Check the status of an experiment run (running, completed, stopped, or interrupted) plus its progress. Works for both platform-run and SDK-logged experiments; pass experimentSlug for SDK runs or runs older than 24h.",
|
|
1779
1880
|
{
|
|
1780
|
-
runId: z2.string().describe("The run ID returned from platform_run_experiment")
|
|
1881
|
+
runId: z2.string().describe("The run ID returned from platform_run_experiment"),
|
|
1882
|
+
experimentSlug: z2.string().optional().describe(
|
|
1883
|
+
"Experiment slug \u2014 required for SDK-logged runs and runs older than 24h (the Redis run-state they would otherwise be read from is absent/expired). Use platform_experiment_list_runs to find the slug."
|
|
1884
|
+
)
|
|
1781
1885
|
},
|
|
1782
1886
|
withToolLogging("platform_experiment_status", async (params) => {
|
|
1783
1887
|
requireApiKey();
|
|
@@ -1821,7 +1925,7 @@ ${lines.join("\n")}`
|
|
|
1821
1925
|
);
|
|
1822
1926
|
server.tool(
|
|
1823
1927
|
"platform_experiment_results",
|
|
1824
|
-
"Fetch per-row results for
|
|
1928
|
+
"Fetch per-row results for an experiment run so you can debug evaluator scores and missed rows. Serves partial results for runs that are still running or were interrupted (it does not require a completed run), with the run status noted in the output. Returns a markdown report: per-evaluator averages plus row-by-row scores and failure details. Output is capped at 50 rows to protect the agent's context window \u2014 narrow with `filter: 'failed'` or `evaluator` to see what matters.",
|
|
1825
1929
|
{
|
|
1826
1930
|
runId: z2.string().describe("The run ID returned from platform_run_experiment"),
|
|
1827
1931
|
experimentSlug: z2.string().optional().describe(
|
|
@@ -2139,4 +2243,4 @@ The token is returned once and cannot be retrieved again.`,
|
|
|
2139
2243
|
export {
|
|
2140
2244
|
createMcpServer
|
|
2141
2245
|
};
|
|
2142
|
-
//# sourceMappingURL=chunk-
|
|
2246
|
+
//# sourceMappingURL=chunk-FVDAZ4XG.js.map
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Minimal type declaration — avoids pulling mcp-server's full dependency tree
|
|
2
2
|
// into the langwatch app's typecheck scope. The actual return type is McpServer,
|
|
3
|
-
// but we only expose the connect()
|
|
3
|
+
// but we only expose the connect() and tool() methods that handler.ts +
|
|
4
|
+
// in-app tool registrations (e.g. registerGovernanceMcpTools) use.
|
|
4
5
|
declare function createMcpServer(): {
|
|
5
6
|
connect(transport: unknown): Promise<void>;
|
|
7
|
+
tool(...args: unknown[]): unknown;
|
|
6
8
|
};
|
|
7
9
|
|
|
8
10
|
export { createMcpServer };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createMcpServer
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-FVDAZ4XG.js";
|
|
4
4
|
import "./chunk-42ZHHHIJ.js";
|
|
5
5
|
import {
|
|
6
6
|
getConfig,
|
|
@@ -179,7 +179,8 @@ async function startHttpServer({
|
|
|
179
179
|
});
|
|
180
180
|
const sseSessions = {};
|
|
181
181
|
app.get("/sse", async (req, res) => {
|
|
182
|
-
const apiKey = extractBearerToken(req) ||
|
|
182
|
+
const apiKey = extractBearerToken(req) || // lgtm[js/sensitive-get-query]
|
|
183
|
+
req.query["apiKey"] || null;
|
|
183
184
|
if (!apiKey) {
|
|
184
185
|
res.status(401).json({
|
|
185
186
|
error: "Authorization: Bearer <LANGWATCH_API_KEY> header or ?apiKey= query parameter required"
|
|
@@ -222,4 +223,4 @@ async function startHttpServer({
|
|
|
222
223
|
export {
|
|
223
224
|
startHttpServer
|
|
224
225
|
};
|
|
225
|
-
//# sourceMappingURL=http-server-
|
|
226
|
+
//# sourceMappingURL=http-server-XUQLIFE7.js.map
|
package/dist/index.js
CHANGED
|
@@ -31109,7 +31109,7 @@ var init_package = __esm({
|
|
|
31109
31109
|
"package.json"() {
|
|
31110
31110
|
package_default = {
|
|
31111
31111
|
name: "@langwatch/mcp-server",
|
|
31112
|
-
version: "0.
|
|
31112
|
+
version: "0.10.0",
|
|
31113
31113
|
description: "An MCP server for Langwatch.",
|
|
31114
31114
|
type: "module",
|
|
31115
31115
|
main: "./dist/index.js",
|
|
@@ -31187,9 +31187,7 @@ var init_package = __esm({
|
|
|
31187
31187
|
},
|
|
31188
31188
|
pnpm: {
|
|
31189
31189
|
overrides: {
|
|
31190
|
-
|
|
31191
|
-
"langsmith@<0.6.0": "0.6.0",
|
|
31192
|
-
"fast-uri@<3.1.2": "3.1.2"
|
|
31190
|
+
liquidjs: ">=10.26.0"
|
|
31193
31191
|
}
|
|
31194
31192
|
}
|
|
31195
31193
|
};
|
|
@@ -31355,6 +31353,24 @@ var init_langwatch_api = __esm({
|
|
|
31355
31353
|
}
|
|
31356
31354
|
});
|
|
31357
31355
|
|
|
31356
|
+
// src/tools/experiment-run-status.ts
|
|
31357
|
+
var INTERRUPTED_THRESHOLD_MS, deriveRunStatus, isTerminalStatus;
|
|
31358
|
+
var init_experiment_run_status = __esm({
|
|
31359
|
+
"src/tools/experiment-run-status.ts"() {
|
|
31360
|
+
"use strict";
|
|
31361
|
+
INTERRUPTED_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
31362
|
+
deriveRunStatus = (timestamps, now = Date.now()) => {
|
|
31363
|
+
if (timestamps.stoppedAt != null) return "stopped";
|
|
31364
|
+
if (timestamps.finishedAt != null) return "completed";
|
|
31365
|
+
if (timestamps.updatedAt != null && now - timestamps.updatedAt > INTERRUPTED_THRESHOLD_MS) {
|
|
31366
|
+
return "interrupted";
|
|
31367
|
+
}
|
|
31368
|
+
return "running";
|
|
31369
|
+
};
|
|
31370
|
+
isTerminalStatus = (status) => status === "completed" || status === "stopped";
|
|
31371
|
+
}
|
|
31372
|
+
});
|
|
31373
|
+
|
|
31358
31374
|
// src/tools/get-experiment-results.ts
|
|
31359
31375
|
async function handleExperimentResults(params) {
|
|
31360
31376
|
const filter = params.filter ?? "all";
|
|
@@ -31378,24 +31394,26 @@ async function handleExperimentResults(params) {
|
|
|
31378
31394
|
"",
|
|
31379
31395
|
"**Status**: not found",
|
|
31380
31396
|
"",
|
|
31381
|
-
`Could not load results for run \`${params.runId}\`. The run may
|
|
31397
|
+
`Could not load results for run \`${params.runId}\`. The run id may be incorrect, or its run state may have expired (Redis keeps it for 24h).`,
|
|
31382
31398
|
"",
|
|
31383
|
-
">
|
|
31399
|
+
"> Pass `experimentSlug` to load results for runs older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` for the run ids."
|
|
31384
31400
|
].join("\n");
|
|
31385
31401
|
}
|
|
31386
31402
|
throw error48;
|
|
31387
31403
|
}
|
|
31388
|
-
if (!results
|
|
31404
|
+
if (!results) {
|
|
31389
31405
|
return [
|
|
31390
31406
|
`# Evaluation Results: ${params.runId}`,
|
|
31391
31407
|
"",
|
|
31392
|
-
"**Status**:
|
|
31408
|
+
"**Status**: not found",
|
|
31393
31409
|
"",
|
|
31394
|
-
`
|
|
31410
|
+
`Could not load results for run \`${params.runId}\`.`,
|
|
31395
31411
|
"",
|
|
31396
|
-
">
|
|
31412
|
+
"> Pass `experimentSlug` if the run is older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` to confirm the run id."
|
|
31397
31413
|
].join("\n");
|
|
31398
31414
|
}
|
|
31415
|
+
const runStatus2 = deriveRunStatus(results.timestamps);
|
|
31416
|
+
const partial2 = !isTerminalStatus(runStatus2);
|
|
31399
31417
|
const evaluationsByRow = /* @__PURE__ */ new Map();
|
|
31400
31418
|
for (const evaluation of results.evaluations) {
|
|
31401
31419
|
if (evaluatorFilter && evaluation.evaluator !== evaluatorFilter) continue;
|
|
@@ -31446,6 +31464,12 @@ async function handleExperimentResults(params) {
|
|
|
31446
31464
|
lines.push(`# Evaluation Results: ${results.runId}`);
|
|
31447
31465
|
lines.push("");
|
|
31448
31466
|
lines.push(`**Experiment**: ${results.experimentId}`);
|
|
31467
|
+
lines.push(`**Status**: ${runStatus2}`);
|
|
31468
|
+
if (typeof results.total === "number" && results.total > 0) {
|
|
31469
|
+
lines.push(
|
|
31470
|
+
`**Progress**: ${results.progress ?? results.dataset.length}/${results.total} rows`
|
|
31471
|
+
);
|
|
31472
|
+
}
|
|
31449
31473
|
lines.push(`**Total rows**: ${results.dataset.length}`);
|
|
31450
31474
|
lines.push(`**Total evaluations**: ${results.evaluations.length}`);
|
|
31451
31475
|
if (filter === "failed") {
|
|
@@ -31454,6 +31478,12 @@ async function handleExperimentResults(params) {
|
|
|
31454
31478
|
if (evaluatorFilter) {
|
|
31455
31479
|
lines.push(`**Evaluator filter**: ${evaluatorFilter}`);
|
|
31456
31480
|
}
|
|
31481
|
+
if (partial2) {
|
|
31482
|
+
lines.push("");
|
|
31483
|
+
lines.push(
|
|
31484
|
+
runStatus2 === "interrupted" ? "> These are partial results. The run never sent a finished/stopped marker and has had no updates recently, so it likely was interrupted before completing. The rows below are everything recorded so far." : "> These are partial results. The run is still in progress, so more rows may appear on a later call."
|
|
31485
|
+
);
|
|
31486
|
+
}
|
|
31457
31487
|
lines.push("");
|
|
31458
31488
|
if (evaluatorAverages.size > 0) {
|
|
31459
31489
|
lines.push("## Evaluator Summary");
|
|
@@ -31469,7 +31499,17 @@ async function handleExperimentResults(params) {
|
|
|
31469
31499
|
lines.push("");
|
|
31470
31500
|
}
|
|
31471
31501
|
if (rows.length === 0) {
|
|
31472
|
-
|
|
31502
|
+
if (filter === "failed") {
|
|
31503
|
+
lines.push("_No rows matched the filter._");
|
|
31504
|
+
} else if (runStatus2 === "running") {
|
|
31505
|
+
lines.push(
|
|
31506
|
+
"_No rows recorded yet. The run is still in progress; call again shortly._"
|
|
31507
|
+
);
|
|
31508
|
+
} else if (runStatus2 === "interrupted") {
|
|
31509
|
+
lines.push("_No rows were recorded before the run was interrupted._");
|
|
31510
|
+
} else {
|
|
31511
|
+
lines.push("_No rows recorded for this run._");
|
|
31512
|
+
}
|
|
31473
31513
|
return lines.join("\n");
|
|
31474
31514
|
}
|
|
31475
31515
|
lines.push(
|
|
@@ -31517,6 +31557,7 @@ var init_get_experiment_results = __esm({
|
|
|
31517
31557
|
"src/tools/get-experiment-results.ts"() {
|
|
31518
31558
|
"use strict";
|
|
31519
31559
|
init_langwatch_api();
|
|
31560
|
+
init_experiment_run_status();
|
|
31520
31561
|
DEFAULT_ROW_CAP = 50;
|
|
31521
31562
|
rowKey = (index, targetId) => `${index}:${targetId ?? ""}`;
|
|
31522
31563
|
summarizeEntry = (entry) => {
|
|
@@ -31713,11 +31754,78 @@ async function handleRunExperiment(params) {
|
|
|
31713
31754
|
);
|
|
31714
31755
|
return lines.join("\n");
|
|
31715
31756
|
}
|
|
31716
|
-
async function
|
|
31717
|
-
const
|
|
31718
|
-
|
|
31719
|
-
|
|
31757
|
+
async function statusFromResults(params) {
|
|
31758
|
+
const search = new URLSearchParams();
|
|
31759
|
+
if (params.experimentSlug) search.set("experimentSlug", params.experimentSlug);
|
|
31760
|
+
const qs = search.toString() ? `?${search.toString()}` : "";
|
|
31761
|
+
let results;
|
|
31762
|
+
try {
|
|
31763
|
+
results = await makeRequest(
|
|
31764
|
+
"GET",
|
|
31765
|
+
`/api/experiments/runs/${encodeURIComponent(params.runId)}/results${qs}`
|
|
31766
|
+
);
|
|
31767
|
+
} catch (error48) {
|
|
31768
|
+
const code = error48 instanceof LangWatchApiError ? error48.status : void 0;
|
|
31769
|
+
const message = error48 instanceof Error ? error48.message : String(error48);
|
|
31770
|
+
if (code === 404 || code === void 0 && /404|not found/i.test(message)) {
|
|
31771
|
+
return null;
|
|
31772
|
+
}
|
|
31773
|
+
throw error48;
|
|
31774
|
+
}
|
|
31775
|
+
const status = deriveRunStatus(results.timestamps);
|
|
31776
|
+
const total = results.total ?? results.dataset?.length ?? 0;
|
|
31777
|
+
const progress = results.progress ?? results.dataset?.length ?? 0;
|
|
31778
|
+
const lines = [];
|
|
31779
|
+
lines.push(`# Evaluation Run ${params.runId}
|
|
31780
|
+
`);
|
|
31781
|
+
lines.push(`**Status**: ${status}`);
|
|
31782
|
+
lines.push(`**Progress**: ${progress}/${total} cells`);
|
|
31783
|
+
if (results.timestamps.createdAt) {
|
|
31784
|
+
lines.push(
|
|
31785
|
+
`**Started**: ${new Date(results.timestamps.createdAt).toISOString()}`
|
|
31786
|
+
);
|
|
31787
|
+
}
|
|
31788
|
+
if (results.timestamps.finishedAt) {
|
|
31789
|
+
lines.push(
|
|
31790
|
+
`**Finished**: ${new Date(results.timestamps.finishedAt).toISOString()}`
|
|
31791
|
+
);
|
|
31792
|
+
}
|
|
31793
|
+
if (results.timestamps.stoppedAt) {
|
|
31794
|
+
lines.push(
|
|
31795
|
+
`**Stopped**: ${new Date(results.timestamps.stoppedAt).toISOString()}`
|
|
31796
|
+
);
|
|
31797
|
+
}
|
|
31798
|
+
lines.push("");
|
|
31799
|
+
lines.push(
|
|
31800
|
+
"> Use `platform_experiment_results` to fetch the per-row scores (partial results are available even while running)."
|
|
31720
31801
|
);
|
|
31802
|
+
return lines.join("\n");
|
|
31803
|
+
}
|
|
31804
|
+
async function handleExperimentStatus(params) {
|
|
31805
|
+
let status;
|
|
31806
|
+
try {
|
|
31807
|
+
status = await makeRequest(
|
|
31808
|
+
"GET",
|
|
31809
|
+
`/api/experiments/runs/${encodeURIComponent(params.runId)}`
|
|
31810
|
+
);
|
|
31811
|
+
} catch (error48) {
|
|
31812
|
+
const code = error48 instanceof LangWatchApiError ? error48.status : void 0;
|
|
31813
|
+
const message = error48 instanceof Error ? error48.message : String(error48);
|
|
31814
|
+
if (code === 404 || /404|not found/i.test(message)) {
|
|
31815
|
+
const fallback = await statusFromResults(params);
|
|
31816
|
+
if (fallback) return fallback;
|
|
31817
|
+
return [
|
|
31818
|
+
`# Evaluation Run ${params.runId}`,
|
|
31819
|
+
"",
|
|
31820
|
+
"**Status**: not found",
|
|
31821
|
+
"",
|
|
31822
|
+
`Could not find run \`${params.runId}\`. SDK-logged runs and runs older than 24h are not in the live run-state and must be resolved by experiment slug.`,
|
|
31823
|
+
"",
|
|
31824
|
+
"> Pass `experimentSlug`: discover it with `platform_experiment_list`, then use `platform_experiment_list_runs` for the run ids. Or fetch the rows directly with `platform_experiment_results`."
|
|
31825
|
+
].join("\n");
|
|
31826
|
+
}
|
|
31827
|
+
throw error48;
|
|
31828
|
+
}
|
|
31721
31829
|
const lines = [];
|
|
31722
31830
|
lines.push(`# Evaluation Run ${status.runId}
|
|
31723
31831
|
`);
|
|
@@ -31752,6 +31860,7 @@ var init_run_experiment = __esm({
|
|
|
31752
31860
|
"src/tools/run-experiment.ts"() {
|
|
31753
31861
|
"use strict";
|
|
31754
31862
|
init_langwatch_api();
|
|
31863
|
+
init_experiment_run_status();
|
|
31755
31864
|
}
|
|
31756
31865
|
});
|
|
31757
31866
|
|
|
@@ -37746,9 +37855,12 @@ ${lines.join("\n")}`
|
|
|
37746
37855
|
);
|
|
37747
37856
|
server.tool(
|
|
37748
37857
|
"platform_experiment_status",
|
|
37749
|
-
"Check the status of an experiment run
|
|
37858
|
+
"Check the status of an experiment run (running, completed, stopped, or interrupted) plus its progress. Works for both platform-run and SDK-logged experiments; pass experimentSlug for SDK runs or runs older than 24h.",
|
|
37750
37859
|
{
|
|
37751
|
-
runId: external_exports.string().describe("The run ID returned from platform_run_experiment")
|
|
37860
|
+
runId: external_exports.string().describe("The run ID returned from platform_run_experiment"),
|
|
37861
|
+
experimentSlug: external_exports.string().optional().describe(
|
|
37862
|
+
"Experiment slug \u2014 required for SDK-logged runs and runs older than 24h (the Redis run-state they would otherwise be read from is absent/expired). Use platform_experiment_list_runs to find the slug."
|
|
37863
|
+
)
|
|
37752
37864
|
},
|
|
37753
37865
|
withToolLogging("platform_experiment_status", async (params) => {
|
|
37754
37866
|
requireApiKey();
|
|
@@ -37792,7 +37904,7 @@ ${lines.join("\n")}`
|
|
|
37792
37904
|
);
|
|
37793
37905
|
server.tool(
|
|
37794
37906
|
"platform_experiment_results",
|
|
37795
|
-
"Fetch per-row results for
|
|
37907
|
+
"Fetch per-row results for an experiment run so you can debug evaluator scores and missed rows. Serves partial results for runs that are still running or were interrupted (it does not require a completed run), with the run status noted in the output. Returns a markdown report: per-evaluator averages plus row-by-row scores and failure details. Output is capped at 50 rows to protect the agent's context window \u2014 narrow with `filter: 'failed'` or `evaluator` to see what matters.",
|
|
37796
37908
|
{
|
|
37797
37909
|
runId: external_exports.string().describe("The run ID returned from platform_run_experiment"),
|
|
37798
37910
|
experimentSlug: external_exports.string().optional().describe(
|
|
@@ -63405,7 +63517,8 @@ async function startHttpServer({
|
|
|
63405
63517
|
});
|
|
63406
63518
|
const sseSessions = {};
|
|
63407
63519
|
app.get("/sse", async (req, res) => {
|
|
63408
|
-
const apiKey = extractBearerToken(req) ||
|
|
63520
|
+
const apiKey = extractBearerToken(req) || // lgtm[js/sensitive-get-query]
|
|
63521
|
+
req.query["apiKey"] || null;
|
|
63409
63522
|
if (!apiKey) {
|
|
63410
63523
|
res.status(401).json({
|
|
63411
63524
|
error: "Authorization: Bearer <LANGWATCH_API_KEY> header or ?apiKey= query parameter required"
|
package/package.json
CHANGED
package/pnpm-workspace.yaml
CHANGED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../langwatch-api.js", async (importOriginal) => {
|
|
4
|
+
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
5
|
+
return {
|
|
6
|
+
...actual,
|
|
7
|
+
makeRequest: vi.fn(),
|
|
8
|
+
};
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
import { LangWatchApiError, makeRequest } from "../langwatch-api.js";
|
|
12
|
+
import { handleExperimentStatus } from "../tools/run-experiment.js";
|
|
13
|
+
import {
|
|
14
|
+
deriveRunStatus,
|
|
15
|
+
isTerminalStatus,
|
|
16
|
+
} from "../tools/experiment-run-status.js";
|
|
17
|
+
|
|
18
|
+
const mockMakeRequest = vi.mocked(makeRequest);
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("deriveRunStatus()", () => {
|
|
25
|
+
describe("when stoppedAt is set", () => {
|
|
26
|
+
it("is stopped", () => {
|
|
27
|
+
expect(deriveRunStatus({ stoppedAt: 5 })).toBe("stopped");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("when finishedAt is set", () => {
|
|
32
|
+
it("is completed", () => {
|
|
33
|
+
expect(deriveRunStatus({ finishedAt: 5 })).toBe("completed");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("when no terminal marker and updates are recent", () => {
|
|
38
|
+
it("is running", () => {
|
|
39
|
+
const now = 1_000_000;
|
|
40
|
+
expect(deriveRunStatus({ updatedAt: now - 1000 }, now)).toBe("running");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("when no terminal marker and no recent updates", () => {
|
|
45
|
+
it("is interrupted", () => {
|
|
46
|
+
const now = 1_000_000;
|
|
47
|
+
expect(deriveRunStatus({ updatedAt: now - 6 * 60 * 1000 }, now)).toBe(
|
|
48
|
+
"interrupted",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("terminal classification", () => {
|
|
54
|
+
it("treats completed and stopped as terminal", () => {
|
|
55
|
+
expect(isTerminalStatus("completed")).toBe(true);
|
|
56
|
+
expect(isTerminalStatus("stopped")).toBe(true);
|
|
57
|
+
expect(isTerminalStatus("running")).toBe(false);
|
|
58
|
+
expect(isTerminalStatus("interrupted")).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("handleExperimentStatus()", () => {
|
|
64
|
+
describe("given the run state exists in Redis", () => {
|
|
65
|
+
it("reports the status straight from the status endpoint", async () => {
|
|
66
|
+
mockMakeRequest.mockResolvedValueOnce({
|
|
67
|
+
runId: "run_1",
|
|
68
|
+
status: "completed",
|
|
69
|
+
progress: 3,
|
|
70
|
+
total: 3,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const out = await handleExperimentStatus({ runId: "run_1" });
|
|
74
|
+
|
|
75
|
+
expect(mockMakeRequest).toHaveBeenCalledWith(
|
|
76
|
+
"GET",
|
|
77
|
+
"/api/experiments/runs/run_1",
|
|
78
|
+
);
|
|
79
|
+
expect(out).toContain("**Status**: completed");
|
|
80
|
+
expect(out).toContain("3/3 cells");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("given an SDK-logged run with no Redis state", () => {
|
|
85
|
+
it("falls back to deriving status from the results endpoint", async () => {
|
|
86
|
+
mockMakeRequest
|
|
87
|
+
.mockRejectedValueOnce(
|
|
88
|
+
new LangWatchApiError("missing", 404, "Run not found"),
|
|
89
|
+
)
|
|
90
|
+
.mockResolvedValueOnce({
|
|
91
|
+
progress: 5,
|
|
92
|
+
total: 5,
|
|
93
|
+
dataset: [1, 2, 3, 4, 5],
|
|
94
|
+
timestamps: {
|
|
95
|
+
createdAt: 1,
|
|
96
|
+
updatedAt: 2,
|
|
97
|
+
finishedAt: 3,
|
|
98
|
+
stoppedAt: null,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const out = await handleExperimentStatus({
|
|
103
|
+
runId: "sdk_run",
|
|
104
|
+
experimentSlug: "doc-qa",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(mockMakeRequest).toHaveBeenNthCalledWith(
|
|
108
|
+
1,
|
|
109
|
+
"GET",
|
|
110
|
+
"/api/experiments/runs/sdk_run",
|
|
111
|
+
);
|
|
112
|
+
expect(mockMakeRequest).toHaveBeenNthCalledWith(
|
|
113
|
+
2,
|
|
114
|
+
"GET",
|
|
115
|
+
"/api/experiments/runs/sdk_run/results?experimentSlug=doc-qa",
|
|
116
|
+
);
|
|
117
|
+
expect(out).toContain("**Status**: completed");
|
|
118
|
+
expect(out).toContain("5/5 cells");
|
|
119
|
+
expect(out).toContain("platform_experiment_results");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("reports interrupted for a stale unfinished SDK run", async () => {
|
|
123
|
+
mockMakeRequest
|
|
124
|
+
.mockRejectedValueOnce(
|
|
125
|
+
new LangWatchApiError("missing", 404, "Run not found"),
|
|
126
|
+
)
|
|
127
|
+
.mockResolvedValueOnce({
|
|
128
|
+
progress: 2,
|
|
129
|
+
total: 5,
|
|
130
|
+
dataset: [1, 2],
|
|
131
|
+
timestamps: {
|
|
132
|
+
createdAt: Date.now() - 60 * 60 * 1000,
|
|
133
|
+
updatedAt: Date.now() - 30 * 60 * 1000,
|
|
134
|
+
finishedAt: null,
|
|
135
|
+
stoppedAt: null,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const out = await handleExperimentStatus({
|
|
140
|
+
runId: "sdk_run",
|
|
141
|
+
experimentSlug: "doc-qa",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(out).toContain("**Status**: interrupted");
|
|
145
|
+
expect(out).toContain("2/5 cells");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("given the run cannot be found anywhere", () => {
|
|
150
|
+
it("returns actionable guidance instead of a raw 404", async () => {
|
|
151
|
+
mockMakeRequest
|
|
152
|
+
.mockRejectedValueOnce(
|
|
153
|
+
new LangWatchApiError("missing", 404, "Run not found"),
|
|
154
|
+
)
|
|
155
|
+
.mockRejectedValueOnce(
|
|
156
|
+
new LangWatchApiError("missing", 404, "Run not found"),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const out = await handleExperimentStatus({
|
|
160
|
+
runId: "nope",
|
|
161
|
+
experimentSlug: "doc-qa",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(out).toContain("not found");
|
|
165
|
+
expect(out).toContain("experimentSlug");
|
|
166
|
+
expect(out).toContain("platform_experiment_results");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("rethrows non-404 errors from the status endpoint", async () => {
|
|
170
|
+
mockMakeRequest.mockRejectedValueOnce(
|
|
171
|
+
new LangWatchApiError("boom", 500, "Internal error"),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await expect(
|
|
175
|
+
handleExperimentStatus({ runId: "nope" }),
|
|
176
|
+
).rejects.toThrow();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("propagates a real (non-404) error from the results fallback instead of faking not-found", async () => {
|
|
180
|
+
mockMakeRequest
|
|
181
|
+
.mockRejectedValueOnce(
|
|
182
|
+
new LangWatchApiError("missing", 404, "Run not found"),
|
|
183
|
+
)
|
|
184
|
+
.mockRejectedValueOnce(
|
|
185
|
+
new LangWatchApiError("boom", 500, "Internal error"),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// The fallback's 500 ("boom") must surface, not the original 404 ("missing").
|
|
189
|
+
await expect(
|
|
190
|
+
handleExperimentStatus({ runId: "sdk_run", experimentSlug: "doc-qa" }),
|
|
191
|
+
).rejects.toThrow(/boom/);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -197,25 +197,116 @@ describe("handleExperimentResults()", () => {
|
|
|
197
197
|
);
|
|
198
198
|
const out = await handleExperimentResults({ runId: "missing" });
|
|
199
199
|
expect(out).toContain("not found");
|
|
200
|
-
expect(out).toContain("
|
|
200
|
+
expect(out).toContain("experimentSlug");
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
describe("given the run is still in progress", () => {
|
|
206
206
|
describe("when results are requested", () => {
|
|
207
|
-
it("
|
|
207
|
+
it("serves the partial rows logged so far with an in-progress note", async () => {
|
|
208
208
|
mockMakeRequest.mockResolvedValueOnce({
|
|
209
209
|
...sample,
|
|
210
210
|
progress: 2,
|
|
211
211
|
total: 3,
|
|
212
|
-
timestamps: {
|
|
212
|
+
timestamps: {
|
|
213
|
+
createdAt: Date.now(),
|
|
214
|
+
updatedAt: Date.now(),
|
|
215
|
+
finishedAt: null,
|
|
216
|
+
stoppedAt: null,
|
|
217
|
+
},
|
|
213
218
|
});
|
|
214
219
|
|
|
215
220
|
const out = await handleExperimentResults({ runId: "unfinished" });
|
|
216
221
|
|
|
217
|
-
expect(out).toContain("
|
|
218
|
-
expect(out).toContain("
|
|
222
|
+
expect(out).toContain("**Status**: running");
|
|
223
|
+
expect(out).toContain("still in progress");
|
|
224
|
+
expect(out).toContain("Row #0");
|
|
225
|
+
expect(out).toContain("Progress**: 2/3");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("given the run was interrupted before finishing", () => {
|
|
231
|
+
describe("when results are requested", () => {
|
|
232
|
+
it("serves the partial rows with an interrupted note", async () => {
|
|
233
|
+
mockMakeRequest.mockResolvedValueOnce({
|
|
234
|
+
...sample,
|
|
235
|
+
timestamps: {
|
|
236
|
+
createdAt: Date.now() - 60 * 60 * 1000,
|
|
237
|
+
updatedAt: Date.now() - 30 * 60 * 1000,
|
|
238
|
+
finishedAt: null,
|
|
239
|
+
stoppedAt: null,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const out = await handleExperimentResults({ runId: "interrupted" });
|
|
244
|
+
|
|
245
|
+
expect(out).toContain("**Status**: interrupted");
|
|
246
|
+
expect(out).toContain("interrupted");
|
|
247
|
+
expect(out).toContain("Row #0");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("given a non-terminal run with zero rows", () => {
|
|
253
|
+
describe("when the run is still running", () => {
|
|
254
|
+
it("says the run is in progress", async () => {
|
|
255
|
+
mockMakeRequest.mockResolvedValueOnce({
|
|
256
|
+
...sample,
|
|
257
|
+
dataset: [],
|
|
258
|
+
evaluations: [],
|
|
259
|
+
timestamps: {
|
|
260
|
+
createdAt: Date.now(),
|
|
261
|
+
updatedAt: Date.now(),
|
|
262
|
+
finishedAt: null,
|
|
263
|
+
stoppedAt: null,
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
const out = await handleExperimentResults({ runId: "empty-running" });
|
|
267
|
+
expect(out).toContain("still in progress");
|
|
268
|
+
expect(out).not.toContain("interrupted");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("when the run was interrupted", () => {
|
|
273
|
+
it("says no rows were recorded before it was interrupted, not to wait", async () => {
|
|
274
|
+
mockMakeRequest.mockResolvedValueOnce({
|
|
275
|
+
...sample,
|
|
276
|
+
dataset: [],
|
|
277
|
+
evaluations: [],
|
|
278
|
+
timestamps: {
|
|
279
|
+
createdAt: Date.now() - 60 * 60 * 1000,
|
|
280
|
+
updatedAt: Date.now() - 30 * 60 * 1000,
|
|
281
|
+
finishedAt: null,
|
|
282
|
+
stoppedAt: null,
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
const out = await handleExperimentResults({ runId: "empty-interrupted" });
|
|
286
|
+
expect(out).toContain("interrupted");
|
|
287
|
+
expect(out).not.toContain("still in progress");
|
|
288
|
+
expect(out).not.toContain("Call again shortly");
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("given no run state but rows exist (SDK / >24h run)", () => {
|
|
294
|
+
describe("when results are requested with a stopped run", () => {
|
|
295
|
+
it("renders results for a stopped run", async () => {
|
|
296
|
+
mockMakeRequest.mockResolvedValueOnce({
|
|
297
|
+
...sample,
|
|
298
|
+
timestamps: {
|
|
299
|
+
createdAt: 0,
|
|
300
|
+
updatedAt: 10,
|
|
301
|
+
finishedAt: null,
|
|
302
|
+
stoppedAt: 20,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const out = await handleExperimentResults({ runId: "stopped" });
|
|
307
|
+
|
|
308
|
+
expect(out).toContain("**Status**: stopped");
|
|
309
|
+
expect(out).toContain("Row #0");
|
|
219
310
|
});
|
|
220
311
|
});
|
|
221
312
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Minimal type declaration — avoids pulling mcp-server's full dependency tree
|
|
2
2
|
// into the langwatch app's typecheck scope. The actual return type is McpServer,
|
|
3
|
-
// but we only expose the connect()
|
|
3
|
+
// but we only expose the connect() and tool() methods that handler.ts +
|
|
4
|
+
// in-app tool registrations (e.g. registerGovernanceMcpTools) use.
|
|
4
5
|
export declare function createMcpServer(): {
|
|
5
6
|
connect(transport: unknown): Promise<void>;
|
|
7
|
+
tool(...args: unknown[]): unknown;
|
|
6
8
|
};
|
package/src/create-mcp-server.ts
CHANGED
|
@@ -1563,9 +1563,15 @@ NOTE: Scenarios can be created two ways. Determine which approach the user needs
|
|
|
1563
1563
|
|
|
1564
1564
|
server.tool(
|
|
1565
1565
|
"platform_experiment_status",
|
|
1566
|
-
"Check the status of an experiment run
|
|
1566
|
+
"Check the status of an experiment run (running, completed, stopped, or interrupted) plus its progress. Works for both platform-run and SDK-logged experiments; pass experimentSlug for SDK runs or runs older than 24h.",
|
|
1567
1567
|
{
|
|
1568
1568
|
runId: z.string().describe("The run ID returned from platform_run_experiment"),
|
|
1569
|
+
experimentSlug: z
|
|
1570
|
+
.string()
|
|
1571
|
+
.optional()
|
|
1572
|
+
.describe(
|
|
1573
|
+
"Experiment slug — required for SDK-logged runs and runs older than 24h (the Redis run-state they would otherwise be read from is absent/expired). Use platform_experiment_list_runs to find the slug.",
|
|
1574
|
+
),
|
|
1569
1575
|
},
|
|
1570
1576
|
withToolLogging("platform_experiment_status", async (params) => {
|
|
1571
1577
|
requireApiKey();
|
|
@@ -1626,7 +1632,7 @@ NOTE: Scenarios can be created two ways. Determine which approach the user needs
|
|
|
1626
1632
|
|
|
1627
1633
|
server.tool(
|
|
1628
1634
|
"platform_experiment_results",
|
|
1629
|
-
"Fetch per-row results for
|
|
1635
|
+
"Fetch per-row results for an experiment run so you can debug evaluator scores and missed rows. Serves partial results for runs that are still running or were interrupted (it does not require a completed run), with the run status noted in the output. Returns a markdown report: per-evaluator averages plus row-by-row scores and failure details. Output is capped at 50 rows to protect the agent's context window — narrow with `filter: 'failed'` or `evaluator` to see what matters.",
|
|
1630
1636
|
{
|
|
1631
1637
|
runId: z
|
|
1632
1638
|
.string()
|
package/src/http-server.ts
CHANGED
|
@@ -271,8 +271,13 @@ export async function startHttpServer({
|
|
|
271
271
|
const sseSessions: Record<string, SseSessionState> = {};
|
|
272
272
|
|
|
273
273
|
app.get("/sse", async (req: Request, res: Response) => {
|
|
274
|
+
// SSE/EventSource clients cannot set custom Authorization headers, so
|
|
275
|
+
// the apiKey fallback via ?apiKey= is the standard pattern for SSE
|
|
276
|
+
// auth across the MCP/CLI ecosystem. The header form is preferred and
|
|
277
|
+
// checked first; this fallback is documented at the client level.
|
|
274
278
|
const apiKey =
|
|
275
279
|
extractBearerToken(req) ||
|
|
280
|
+
// lgtm[js/sensitive-get-query]
|
|
276
281
|
(req.query["apiKey"] as string | undefined) ||
|
|
277
282
|
null;
|
|
278
283
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Experiment runs are stored as a timestamp-driven state in ClickHouse: a run
|
|
2
|
+
// is "completed" once finishedAt is set, "stopped" once stoppedAt is set, and
|
|
3
|
+
// otherwise still "running". A run that stopped emitting updates a while ago
|
|
4
|
+
// without ever finishing is reported as "interrupted": the SDK process likely
|
|
5
|
+
// died before sending finished_at/stopped_at.
|
|
6
|
+
|
|
7
|
+
export const INTERRUPTED_THRESHOLD_MS = 5 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
export type RunStatus = "completed" | "stopped" | "running" | "interrupted";
|
|
10
|
+
|
|
11
|
+
export interface RunTimestamps {
|
|
12
|
+
createdAt?: number | null;
|
|
13
|
+
updatedAt?: number | null;
|
|
14
|
+
finishedAt?: number | null;
|
|
15
|
+
stoppedAt?: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const deriveRunStatus = (
|
|
19
|
+
timestamps: RunTimestamps,
|
|
20
|
+
now: number = Date.now(),
|
|
21
|
+
): RunStatus => {
|
|
22
|
+
if (timestamps.stoppedAt != null) return "stopped";
|
|
23
|
+
if (timestamps.finishedAt != null) return "completed";
|
|
24
|
+
if (
|
|
25
|
+
timestamps.updatedAt != null &&
|
|
26
|
+
now - timestamps.updatedAt > INTERRUPTED_THRESHOLD_MS
|
|
27
|
+
) {
|
|
28
|
+
return "interrupted";
|
|
29
|
+
}
|
|
30
|
+
return "running";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Whether the run is in a terminal state. Non-terminal runs (running /
|
|
34
|
+
// interrupted) can still be queried; their results are partial.
|
|
35
|
+
export const isTerminalStatus = (status: RunStatus): boolean =>
|
|
36
|
+
status === "completed" || status === "stopped";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LangWatchApiError, makeRequest } from "../langwatch-api.js";
|
|
2
|
+
import { deriveRunStatus, isTerminalStatus } from "./experiment-run-status.js";
|
|
2
3
|
|
|
3
4
|
interface DatasetEntry {
|
|
4
5
|
index: number;
|
|
@@ -110,26 +111,34 @@ export async function handleExperimentResults(params: {
|
|
|
110
111
|
"",
|
|
111
112
|
"**Status**: not found",
|
|
112
113
|
"",
|
|
113
|
-
`Could not load results for run \`${params.runId}\`. The run may
|
|
114
|
+
`Could not load results for run \`${params.runId}\`. The run id may be incorrect, or its run state may have expired (Redis keeps it for 24h).`,
|
|
114
115
|
"",
|
|
115
|
-
">
|
|
116
|
+
"> Pass `experimentSlug` to load results for runs older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` for the run ids.",
|
|
116
117
|
].join("\n");
|
|
117
118
|
}
|
|
118
119
|
throw error;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
if (!results
|
|
122
|
+
if (!results) {
|
|
122
123
|
return [
|
|
123
124
|
`# Evaluation Results: ${params.runId}`,
|
|
124
125
|
"",
|
|
125
|
-
"**Status**:
|
|
126
|
+
"**Status**: not found",
|
|
126
127
|
"",
|
|
127
|
-
`
|
|
128
|
+
`Could not load results for run \`${params.runId}\`.`,
|
|
128
129
|
"",
|
|
129
|
-
">
|
|
130
|
+
"> Pass `experimentSlug` if the run is older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` to confirm the run id.",
|
|
130
131
|
].join("\n");
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
// Partial results are served even while the run is still in progress.
|
|
135
|
+
// Rows land in ClickHouse incrementally, so a "running" or "interrupted"
|
|
136
|
+
// run can still expose every row recorded so far. A run only fails to set
|
|
137
|
+
// finished_at/stopped_at when the SDK process dies before flushing, but the
|
|
138
|
+
// rows it did log are still useful, so we never gate on a terminal status.
|
|
139
|
+
const runStatus = deriveRunStatus(results.timestamps);
|
|
140
|
+
const partial = !isTerminalStatus(runStatus);
|
|
141
|
+
|
|
133
142
|
// Group evaluations by target-scoped row key, applying evaluator filter.
|
|
134
143
|
const evaluationsByRow = new Map<string, EvaluationItem[]>();
|
|
135
144
|
for (const evaluation of results.evaluations) {
|
|
@@ -196,6 +205,12 @@ export async function handleExperimentResults(params: {
|
|
|
196
205
|
lines.push(`# Evaluation Results: ${results.runId}`);
|
|
197
206
|
lines.push("");
|
|
198
207
|
lines.push(`**Experiment**: ${results.experimentId}`);
|
|
208
|
+
lines.push(`**Status**: ${runStatus}`);
|
|
209
|
+
if (typeof results.total === "number" && results.total > 0) {
|
|
210
|
+
lines.push(
|
|
211
|
+
`**Progress**: ${results.progress ?? results.dataset.length}/${results.total} rows`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
199
214
|
lines.push(`**Total rows**: ${results.dataset.length}`);
|
|
200
215
|
lines.push(`**Total evaluations**: ${results.evaluations.length}`);
|
|
201
216
|
if (filter === "failed") {
|
|
@@ -204,6 +219,14 @@ export async function handleExperimentResults(params: {
|
|
|
204
219
|
if (evaluatorFilter) {
|
|
205
220
|
lines.push(`**Evaluator filter**: ${evaluatorFilter}`);
|
|
206
221
|
}
|
|
222
|
+
if (partial) {
|
|
223
|
+
lines.push("");
|
|
224
|
+
lines.push(
|
|
225
|
+
runStatus === "interrupted"
|
|
226
|
+
? "> These are partial results. The run never sent a finished/stopped marker and has had no updates recently, so it likely was interrupted before completing. The rows below are everything recorded so far."
|
|
227
|
+
: "> These are partial results. The run is still in progress, so more rows may appear on a later call.",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
207
230
|
lines.push("");
|
|
208
231
|
|
|
209
232
|
if (evaluatorAverages.size > 0) {
|
|
@@ -222,7 +245,17 @@ export async function handleExperimentResults(params: {
|
|
|
222
245
|
}
|
|
223
246
|
|
|
224
247
|
if (rows.length === 0) {
|
|
225
|
-
|
|
248
|
+
if (filter === "failed") {
|
|
249
|
+
lines.push("_No rows matched the filter._");
|
|
250
|
+
} else if (runStatus === "running") {
|
|
251
|
+
lines.push(
|
|
252
|
+
"_No rows recorded yet. The run is still in progress; call again shortly._",
|
|
253
|
+
);
|
|
254
|
+
} else if (runStatus === "interrupted") {
|
|
255
|
+
lines.push("_No rows were recorded before the run was interrupted._");
|
|
256
|
+
} else {
|
|
257
|
+
lines.push("_No rows recorded for this run._");
|
|
258
|
+
}
|
|
226
259
|
return lines.join("\n");
|
|
227
260
|
}
|
|
228
261
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { makeRequest } from "../langwatch-api.js";
|
|
1
|
+
import { LangWatchApiError, makeRequest } from "../langwatch-api.js";
|
|
2
|
+
import { deriveRunStatus } from "./experiment-run-status.js";
|
|
2
3
|
|
|
3
4
|
interface EvaluationRunResponse {
|
|
4
5
|
runId: string;
|
|
@@ -45,13 +46,107 @@ export async function handleRunExperiment(params: {
|
|
|
45
46
|
return lines.join("\n");
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
interface ResultsForStatus {
|
|
50
|
+
progress?: number | null;
|
|
51
|
+
total?: number | null;
|
|
52
|
+
dataset?: unknown[];
|
|
53
|
+
timestamps: {
|
|
54
|
+
createdAt?: number | null;
|
|
55
|
+
updatedAt?: number | null;
|
|
56
|
+
finishedAt?: number | null;
|
|
57
|
+
stoppedAt?: number | null;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// SDK-logged runs (langwatch.experiment + evaluation.log) never populate the
|
|
62
|
+
// Redis run-state that GET /runs/:runId reads, so that endpoint 404s for them.
|
|
63
|
+
// Their data lives only in ClickHouse, reachable through the results endpoint,
|
|
64
|
+
// so we derive the status from there as a fallback. experimentSlug is required
|
|
65
|
+
// because runId is not unique across experiments once the Redis state expires.
|
|
66
|
+
async function statusFromResults(params: {
|
|
67
|
+
runId: string;
|
|
68
|
+
experimentSlug?: string;
|
|
69
|
+
}): Promise<string | null> {
|
|
70
|
+
const search = new URLSearchParams();
|
|
71
|
+
if (params.experimentSlug) search.set("experimentSlug", params.experimentSlug);
|
|
72
|
+
const qs = search.toString() ? `?${search.toString()}` : "";
|
|
73
|
+
|
|
74
|
+
let results: ResultsForStatus;
|
|
75
|
+
try {
|
|
76
|
+
results = (await makeRequest(
|
|
77
|
+
"GET",
|
|
78
|
+
`/api/experiments/runs/${encodeURIComponent(params.runId)}/results${qs}`,
|
|
79
|
+
)) as ResultsForStatus;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// Only a genuine "no such run" is a fallback miss (-> guidance). Real
|
|
82
|
+
// 5xx / auth / network errors must propagate, not be masked as not-found.
|
|
83
|
+
const code = error instanceof LangWatchApiError ? error.status : undefined;
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
if (code === 404 || (code === undefined && /404|not found/i.test(message))) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const status = deriveRunStatus(results.timestamps);
|
|
92
|
+
const total = results.total ?? results.dataset?.length ?? 0;
|
|
93
|
+
const progress = results.progress ?? results.dataset?.length ?? 0;
|
|
94
|
+
|
|
95
|
+
const lines: string[] = [];
|
|
96
|
+
lines.push(`# Evaluation Run ${params.runId}\n`);
|
|
97
|
+
lines.push(`**Status**: ${status}`);
|
|
98
|
+
lines.push(`**Progress**: ${progress}/${total} cells`);
|
|
99
|
+
if (results.timestamps.createdAt) {
|
|
100
|
+
lines.push(
|
|
101
|
+
`**Started**: ${new Date(results.timestamps.createdAt).toISOString()}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (results.timestamps.finishedAt) {
|
|
105
|
+
lines.push(
|
|
106
|
+
`**Finished**: ${new Date(results.timestamps.finishedAt).toISOString()}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (results.timestamps.stoppedAt) {
|
|
110
|
+
lines.push(
|
|
111
|
+
`**Stopped**: ${new Date(results.timestamps.stoppedAt).toISOString()}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push(
|
|
116
|
+
"> Use `platform_experiment_results` to fetch the per-row scores (partial results are available even while running).",
|
|
117
|
+
);
|
|
118
|
+
return lines.join("\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
48
121
|
export async function handleExperimentStatus(params: {
|
|
49
122
|
runId: string;
|
|
123
|
+
experimentSlug?: string;
|
|
50
124
|
}): Promise<string> {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
125
|
+
let status: EvaluationStatusResponse;
|
|
126
|
+
try {
|
|
127
|
+
status = (await makeRequest(
|
|
128
|
+
"GET",
|
|
129
|
+
`/api/experiments/runs/${encodeURIComponent(params.runId)}`,
|
|
130
|
+
)) as EvaluationStatusResponse;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const code =
|
|
133
|
+
error instanceof LangWatchApiError ? error.status : undefined;
|
|
134
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
135
|
+
if (code === 404 || /404|not found/i.test(message)) {
|
|
136
|
+
const fallback = await statusFromResults(params);
|
|
137
|
+
if (fallback) return fallback;
|
|
138
|
+
return [
|
|
139
|
+
`# Evaluation Run ${params.runId}`,
|
|
140
|
+
"",
|
|
141
|
+
"**Status**: not found",
|
|
142
|
+
"",
|
|
143
|
+
`Could not find run \`${params.runId}\`. SDK-logged runs and runs older than 24h are not in the live run-state and must be resolved by experiment slug.`,
|
|
144
|
+
"",
|
|
145
|
+
"> Pass `experimentSlug`: discover it with `platform_experiment_list`, then use `platform_experiment_list_runs` for the run ids. Or fetch the rows directly with `platform_experiment_results`.",
|
|
146
|
+
].join("\n");
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
55
150
|
|
|
56
151
|
const lines: string[] = [];
|
|
57
152
|
lines.push(`# Evaluation Run ${status.runId}\n`);
|