@langwatch/mcp-server 0.9.0 → 0.10.1
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 +27 -0
- package/dist/{archive-project-HZBRPR2S.js → archive-project-KAZFHR4A.js} +3 -3
- package/dist/{archive-scenario-P5RRITRM.js → archive-scenario-6M2IB7Y2.js} +3 -3
- package/dist/{archive-suite-NUUFD2EM.js → archive-suite-NXZ3YDS3.js} +3 -3
- package/dist/{assign-prompt-tag-FKCCZPML.js → assign-prompt-tag-WPUJO55U.js} +2 -2
- package/dist/{chunk-X2SEURT2.js → chunk-AGGE6NPR.js} +2 -2
- package/dist/{chunk-ADSJ2FDA.js → chunk-B6UV3JVI.js} +2 -2
- package/dist/{chunk-42ZHHHIJ.js → chunk-CAY57CKF.js} +3 -1
- package/dist/{chunk-BBJ5WAJB.js → chunk-COCNCXVJ.js} +2 -2
- package/dist/{chunk-BYWYM2RP.js → chunk-ESGZVPPC.js} +2 -2
- package/dist/{chunk-R7C7OUVG.js → chunk-GWEW5XON.js} +2 -2
- package/dist/{chunk-JVLJKQJG.js → chunk-HRFQAFPR.js} +204 -105
- package/dist/{chunk-TROIWSEQ.js → chunk-ITN2QGHF.js} +2 -2
- package/dist/{chunk-XC62KJIT.js → chunk-IUP5IHBD.js} +3 -3
- package/dist/{chunk-CCM4ZFGF.js → chunk-LTEJDL4V.js} +2 -2
- package/dist/{chunk-BB7EOKHR.js → chunk-RDUXCCFR.js} +2 -2
- package/dist/{chunk-JHOSVOHT.js → chunk-U2QIW2SW.js} +2 -2
- package/dist/{chunk-CHKCEZBP.js → chunk-UKRCZJJC.js} +2 -2
- package/dist/{chunk-HKY3GW45.js → chunk-XRQNXN2Z.js} +2 -2
- package/dist/{create-agent-K5Q7BFGZ.js → create-agent-BI7UTJ6A.js} +3 -3
- package/dist/{create-annotation-KN3GE3DK.js → create-annotation-SNM2J6S4.js} +3 -3
- package/dist/{create-api-key-OQI5PUNQ.js → create-api-key-23MXYC2L.js} +3 -3
- package/dist/{create-dashboard-G4QA5AXP.js → create-dashboard-CPOOOIKK.js} +3 -3
- package/dist/{create-dataset-WQSC37N7.js → create-dataset-BVXPVVY2.js} +3 -3
- package/dist/{create-dataset-records-B4RL3FLC.js → create-dataset-records-S7VFIIMR.js} +3 -3
- package/dist/{create-evaluator-YFFQIR7E.js → create-evaluator-WJAAQOUE.js} +3 -3
- package/dist/create-mcp-server.d.ts +3 -1
- package/dist/create-mcp-server.js +2 -2
- package/dist/{create-project-CUVQ67VN.js → create-project-BF7LMZH5.js} +3 -3
- package/dist/{create-prompt-NGXKSP4X.js → create-prompt-LZ3QHSU4.js} +2 -2
- package/dist/{create-prompt-tag-IUKYPVHH.js → create-prompt-tag-IF4J24QM.js} +2 -2
- package/dist/{create-scenario-FNBXALH3.js → create-scenario-BK4E5DEU.js} +3 -3
- package/dist/{create-suite-DJEPD4ZB.js → create-suite-HEQPNLND.js} +3 -3
- package/dist/{delete-agent-TJI5XBBM.js → delete-agent-Y4T2G5PT.js} +3 -3
- package/dist/{delete-annotation-KRP53TUU.js → delete-annotation-KYDMQTZG.js} +3 -3
- package/dist/{delete-dashboard-N5SZJIR3.js → delete-dashboard-3RW647WJ.js} +3 -3
- package/dist/{delete-dataset-KDUOHL2P.js → delete-dataset-SZAHFHHS.js} +3 -3
- package/dist/{delete-dataset-records-DM4W3PAW.js → delete-dataset-records-ISHKGEBH.js} +3 -3
- package/dist/{delete-evaluator-7ZATLJOA.js → delete-evaluator-VKIG5I3S.js} +3 -3
- package/dist/{delete-prompt-tag-I6HNMSVJ.js → delete-prompt-tag-VYYL4GTF.js} +2 -2
- package/dist/{delete-workflow-QEO74JQR.js → delete-workflow-SHNPYSWQ.js} +3 -3
- package/dist/{get-agent-3KDWCP5N.js → get-agent-IZSP7AR5.js} +3 -3
- package/dist/{get-analytics-GPVJX7SF.js → get-analytics-EDFAYYIQ.js} +2 -2
- package/dist/{get-dashboard-YPTIE5YY.js → get-dashboard-WSAO6IQ4.js} +3 -3
- package/dist/{get-dataset-6DQFYITM.js → get-dataset-BEVNXQNF.js} +3 -3
- package/dist/{get-evaluator-PCM6XQAU.js → get-evaluator-HCVXNPZR.js} +3 -3
- package/dist/{get-project-JCMWXVLU.js → get-project-H4V3GYRQ.js} +3 -3
- package/dist/{get-prompt-5XAGWWBG.js → get-prompt-EWRJVVGB.js} +2 -2
- package/dist/{get-scenario-7XBCDZWX.js → get-scenario-24YUJS5W.js} +3 -3
- package/dist/{get-simulation-run-AV24FJWI.js → get-simulation-run-UX2W6F6I.js} +3 -3
- package/dist/{get-suite-4DBVHFEM.js → get-suite-B65ZKZJA.js} +3 -3
- package/dist/{get-trace-MRBDS3HJ.js → get-trace-ZR7MIITH.js} +2 -2
- package/dist/{get-workflow-UR4Y6UFX.js → get-workflow-YROHUQJW.js} +3 -3
- package/dist/{http-server-DEGNV5EP.js → http-server-GXVEDA7G.js} +5 -4
- package/dist/index.js +2660 -1497
- package/dist/{langwatch-api-annotations-ZJDRB4JH.js → langwatch-api-annotations-OPEXNTPF.js} +3 -3
- package/dist/{langwatch-api-dashboards-WARLI6NF.js → langwatch-api-dashboards-NPQM6QJI.js} +3 -3
- package/dist/{langwatch-api-monitors-4XKXHYCG.js → langwatch-api-monitors-3L25OOOS.js} +2 -2
- package/dist/{langwatch-api-secrets-6SHK5G7Q.js → langwatch-api-secrets-ZFURUA62.js} +2 -2
- package/dist/{langwatch-api-triggers-5SX6R2WU.js → langwatch-api-triggers-XGXXT7DD.js} +2 -2
- package/dist/{langwatch-api-workflows-NLJENNXE.js → langwatch-api-workflows-CXCTTCXT.js} +3 -3
- package/dist/{list-agents-NECO7MPI.js → list-agents-6ZNOV7FK.js} +3 -3
- package/dist/{list-annotations-3OJEO774.js → list-annotations-6P3IWILH.js} +3 -3
- package/dist/{list-api-keys-AWW7ROLB.js → list-api-keys-JJI62KD2.js} +3 -3
- package/dist/{list-dashboards-DVPVVVKW.js → list-dashboards-G2WJOXWB.js} +3 -3
- package/dist/{list-dataset-records-I4BYORF5.js → list-dataset-records-GWWVOCII.js} +3 -3
- package/dist/{list-datasets-WUAE5HGX.js → list-datasets-HBTH33X4.js} +3 -3
- package/dist/{list-evaluators-B6OSVAM2.js → list-evaluators-K5OGB6TX.js} +3 -3
- package/dist/{list-model-providers-NL6IP4UZ.js → list-model-providers-JMOGZLSO.js} +3 -3
- package/dist/{list-projects-5FHMJ4PQ.js → list-projects-OXJC2NW5.js} +3 -3
- package/dist/{list-prompt-tags-Z2DD6FLQ.js → list-prompt-tags-J2HPHVAZ.js} +2 -2
- package/dist/{list-prompts-VNXKHK7T.js → list-prompts-C7GR72XL.js} +2 -2
- package/dist/{list-scenarios-CFZHXL76.js → list-scenarios-JEPGIIDO.js} +3 -3
- package/dist/{list-simulation-runs-AM2BXLBW.js → list-simulation-runs-DRSAGPEC.js} +3 -3
- package/dist/{list-suites-ZDOHAAXF.js → list-suites-POTZ3ZT6.js} +3 -3
- package/dist/{list-workflows-WBRBKLIT.js → list-workflows-QQZQIOHB.js} +3 -3
- package/dist/{rename-prompt-tag-A4RJUK7Z.js → rename-prompt-tag-4AAV2BS4.js} +2 -2
- package/dist/{revoke-api-key-ZSPSPF5G.js → revoke-api-key-TONB4ADQ.js} +3 -3
- package/dist/{run-agent-JFNDY7MR.js → run-agent-7QE3YEH7.js} +3 -3
- package/dist/{run-suite-TH7LO5TJ.js → run-suite-Q66OSPO4.js} +3 -3
- package/dist/{run-workflow-CK3JP5U2.js → run-workflow-RWX2D752.js} +3 -3
- package/dist/{search-traces-3BBVYPC3.js → search-traces-M5E5VB4X.js} +2 -2
- package/dist/{set-model-provider-WOC5FNMV.js → set-model-provider-XENT5D2P.js} +3 -3
- package/dist/{update-agent-YMEV5MTL.js → update-agent-GML25EE3.js} +3 -3
- package/dist/{update-dataset-EYT5POFM.js → update-dataset-R4MM6NNY.js} +3 -3
- package/dist/{update-dataset-record-VFF5JTX4.js → update-dataset-record-FN4BL5P4.js} +3 -3
- package/dist/{update-evaluator-6IJHFG2E.js → update-evaluator-LH2RDDB5.js} +3 -3
- package/dist/{update-project-OIT4WPFI.js → update-project-H53IHRBB.js} +3 -3
- package/dist/{update-prompt-ZU2DD6KM.js → update-prompt-M33OTTAJ.js} +2 -2
- package/dist/{update-scenario-3VUTPDRU.js → update-scenario-FKTQRAFQ.js} +3 -3
- package/dist/{update-suite-C6JFDQHL.js → update-suite-JJTNOTXN.js} +3 -3
- package/package.json +1 -1
- package/pnpm-workspace.yaml +13 -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
|
@@ -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`);
|