@langwatch/mcp-server 0.10.1 → 2.0.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/.python-version +1 -0
- package/.release-please-shim +1 -1
- package/CHANGELOG.md +75 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +64 -18
- package/dist/{archive-project-KAZFHR4A.js → archive-project-4EQGLBJJ.js} +5 -4
- package/dist/{archive-scenario-6M2IB7Y2.js → archive-scenario-W6BXYDZ3.js} +5 -4
- package/dist/{archive-suite-NXZ3YDS3.js → archive-suite-4T6BSRS7.js} +5 -4
- package/dist/{assign-prompt-tag-WPUJO55U.js → assign-prompt-tag-VLXUWEKU.js} +4 -3
- package/dist/chunk-37SMPMQS.js +12 -0
- package/dist/{chunk-HRFQAFPR.js → chunk-4QC7SA2L.js} +221 -134
- package/dist/{chunk-XRQNXN2Z.js → chunk-5CCZFUXS.js} +2 -2
- package/dist/{chunk-ITN2QGHF.js → chunk-77BNWQ3U.js} +2 -2
- package/dist/{chunk-B6UV3JVI.js → chunk-7RHG56OH.js} +2 -2
- package/dist/{chunk-CAY57CKF.js → chunk-A2LEVYQ5.js} +78 -5
- package/dist/{chunk-U2QIW2SW.js → chunk-AQJO2EI5.js} +2 -2
- package/dist/{chunk-GWEW5XON.js → chunk-CDYQOY5U.js} +2 -2
- package/dist/{chunk-LTEJDL4V.js → chunk-J42FWRJ2.js} +2 -2
- package/dist/chunk-SAS5OB7F.js +58 -0
- package/dist/{chunk-AGGE6NPR.js → chunk-SFB4MU6H.js} +2 -2
- package/dist/{chunk-COCNCXVJ.js → chunk-TJCXFNDT.js} +2 -2
- package/dist/chunk-TOXJQDIZ.js +432 -0
- package/dist/chunk-UJPXFA26.js +267 -0
- package/dist/{chunk-KOO5733S.js → chunk-WT4PO4A3.js} +8 -2
- package/dist/{chunk-RDUXCCFR.js → chunk-YX3A3CFM.js} +2 -2
- package/dist/{chunk-UKRCZJJC.js → chunk-YZLYHLLI.js} +2 -2
- package/dist/{chunk-ESGZVPPC.js → chunk-ZWIL4EFL.js} +2 -2
- package/dist/config.js +2 -1
- package/dist/{create-agent-BI7UTJ6A.js → create-agent-7XHO6UIS.js} +5 -4
- package/dist/{create-annotation-SNM2J6S4.js → create-annotation-YQXRQC6A.js} +5 -4
- package/dist/{create-api-key-23MXYC2L.js → create-api-key-YUS36KBU.js} +5 -4
- package/dist/{create-dashboard-CPOOOIKK.js → create-dashboard-4Z5JO5LB.js} +5 -4
- package/dist/{create-dataset-BVXPVVY2.js → create-dataset-E6NMSVX7.js} +5 -4
- package/dist/{create-dataset-records-S7VFIIMR.js → create-dataset-records-SKHPRD2K.js} +5 -4
- package/dist/{create-evaluator-WJAAQOUE.js → create-evaluator-JXF5PF33.js} +5 -4
- package/dist/create-mcp-server.js +4 -3
- package/dist/{create-project-BF7LMZH5.js → create-project-CS2CB57Y.js} +5 -4
- package/dist/{create-prompt-LZ3QHSU4.js → create-prompt-F5RMX3L3.js} +4 -3
- package/dist/{create-prompt-tag-IF4J24QM.js → create-prompt-tag-GMRWTEFR.js} +4 -3
- package/dist/{create-scenario-BK4E5DEU.js → create-scenario-JDR2VB2H.js} +5 -4
- package/dist/{create-suite-HEQPNLND.js → create-suite-LZS6QIAF.js} +5 -4
- package/dist/{delete-agent-Y4T2G5PT.js → delete-agent-O3EEH4S5.js} +5 -4
- package/dist/{delete-annotation-KYDMQTZG.js → delete-annotation-XP6FQM7A.js} +5 -4
- package/dist/{delete-dashboard-3RW647WJ.js → delete-dashboard-RFUNYCNP.js} +5 -4
- package/dist/{delete-dataset-SZAHFHHS.js → delete-dataset-3MJFRDLP.js} +5 -4
- package/dist/{delete-dataset-records-ISHKGEBH.js → delete-dataset-records-WPMU5GL5.js} +5 -4
- package/dist/{delete-evaluator-VKIG5I3S.js → delete-evaluator-33SP2JFX.js} +5 -4
- package/dist/{delete-prompt-tag-VYYL4GTF.js → delete-prompt-tag-KNMZVSPB.js} +4 -3
- package/dist/{delete-workflow-SHNPYSWQ.js → delete-workflow-3NUXO46U.js} +5 -4
- package/dist/discover-evaluator-schema-XTAD4X7D.js +2191 -0
- package/dist/{discover-scenario-schema-MEEEVND7.js → discover-scenario-schema-FE64QK2D.js} +3 -1
- package/dist/{discover-schema-3T52ORPB.js → discover-schema-L6QDPON3.js} +3 -1
- package/dist/{get-agent-IZSP7AR5.js → get-agent-Y7MWZ65F.js} +5 -4
- package/dist/{get-analytics-EDFAYYIQ.js → get-analytics-TETCSOK6.js} +4 -3
- package/dist/{get-dashboard-WSAO6IQ4.js → get-dashboard-KHBEGZGL.js} +5 -4
- package/dist/{get-dataset-BEVNXQNF.js → get-dataset-RCMU5VAH.js} +5 -4
- package/dist/{get-evaluator-HCVXNPZR.js → get-evaluator-ADMKVWYZ.js} +5 -4
- package/dist/{get-project-H4V3GYRQ.js → get-project-RAK7URQJ.js} +5 -4
- package/dist/{get-prompt-EWRJVVGB.js → get-prompt-OY5B7TRI.js} +4 -3
- package/dist/{get-scenario-24YUJS5W.js → get-scenario-ZTAJJL5T.js} +5 -4
- package/dist/{get-simulation-run-UX2W6F6I.js → get-simulation-run-T2ZQWYH2.js} +5 -4
- package/dist/{get-suite-B65ZKZJA.js → get-suite-J7JVWO6O.js} +5 -4
- package/dist/{get-trace-ZR7MIITH.js → get-trace-K7U234Q4.js} +8 -10
- package/dist/{get-workflow-YROHUQJW.js → get-workflow-L5L2PSK3.js} +5 -4
- package/dist/http-security-EU3CHTY7.js +26 -0
- package/dist/http-server-YXHFPPGA.js +24706 -0
- package/dist/index.js +23280 -3135
- package/dist/{langwatch-api-annotations-OPEXNTPF.js → langwatch-api-annotations-FDAS6NQ7.js} +5 -4
- package/dist/{langwatch-api-dashboards-NPQM6QJI.js → langwatch-api-dashboards-JTVCS7ZJ.js} +5 -4
- package/dist/{langwatch-api-monitors-3L25OOOS.js → langwatch-api-monitors-3J7UIQME.js} +4 -3
- package/dist/{langwatch-api-secrets-ZFURUA62.js → langwatch-api-secrets-BUBSNCNO.js} +4 -3
- package/dist/{langwatch-api-triggers-XGXXT7DD.js → langwatch-api-triggers-EDFUCJN6.js} +4 -3
- package/dist/langwatch-api-workflows-NBJX5KOG.js +16 -0
- package/dist/{list-agents-6ZNOV7FK.js → list-agents-KFHKGPXV.js} +5 -4
- package/dist/{list-annotations-6P3IWILH.js → list-annotations-ZTVP4EEL.js} +5 -4
- package/dist/{list-api-keys-JJI62KD2.js → list-api-keys-CWD4UQT5.js} +5 -4
- package/dist/{list-dashboards-G2WJOXWB.js → list-dashboards-PWG6P6KR.js} +5 -4
- package/dist/{list-dataset-records-GWWVOCII.js → list-dataset-records-IOE7LQXS.js} +5 -4
- package/dist/{list-datasets-HBTH33X4.js → list-datasets-ZT4JYUDT.js} +5 -4
- package/dist/{list-evaluators-K5OGB6TX.js → list-evaluators-SJHC7VBH.js} +5 -4
- package/dist/{list-model-providers-JMOGZLSO.js → list-model-providers-PPD2WPD2.js} +5 -4
- package/dist/{list-projects-OXJC2NW5.js → list-projects-K7QAIX2I.js} +5 -4
- package/dist/{list-prompt-tags-J2HPHVAZ.js → list-prompt-tags-FOO6TNHB.js} +4 -3
- package/dist/{list-prompts-C7GR72XL.js → list-prompts-SC2HAUFG.js} +4 -3
- package/dist/{list-scenarios-JEPGIIDO.js → list-scenarios-F2WYNRIE.js} +5 -4
- package/dist/{list-simulation-runs-DRSAGPEC.js → list-simulation-runs-KF5THG5H.js} +5 -4
- package/dist/{list-suites-POTZ3ZT6.js → list-suites-CRY4VWW6.js} +5 -4
- package/dist/{list-workflows-QQZQIOHB.js → list-workflows-QSJWURCK.js} +5 -4
- package/dist/{rename-prompt-tag-4AAV2BS4.js → rename-prompt-tag-EKWU5QF3.js} +4 -3
- package/dist/report-issue-XYVAPAYJ.js +412 -0
- package/dist/{revoke-api-key-TONB4ADQ.js → revoke-api-key-FTRTZMOF.js} +5 -4
- package/dist/{run-agent-7QE3YEH7.js → run-agent-6SM4XNA7.js} +5 -4
- package/dist/{run-suite-Q66OSPO4.js → run-suite-NKZCJ2IN.js} +5 -4
- package/dist/{run-workflow-RWX2D752.js → run-workflow-QPXC627O.js} +5 -4
- package/dist/{search-traces-M5E5VB4X.js → search-traces-A6HFKPLJ.js} +10 -3
- package/dist/{set-model-provider-XENT5D2P.js → set-model-provider-3WKT63ZN.js} +5 -4
- package/dist/{update-agent-GML25EE3.js → update-agent-SMAEHLXH.js} +5 -4
- package/dist/{update-dataset-R4MM6NNY.js → update-dataset-BDUK7TUO.js} +5 -4
- package/dist/{update-dataset-record-FN4BL5P4.js → update-dataset-record-4ZSDRBSW.js} +5 -4
- package/dist/{update-evaluator-LH2RDDB5.js → update-evaluator-LS6TIBY7.js} +5 -4
- package/dist/{update-project-H53IHRBB.js → update-project-LKVYXENH.js} +5 -4
- package/dist/{update-prompt-M33OTTAJ.js → update-prompt-ODCRQRN6.js} +4 -3
- package/dist/{update-scenario-FKTQRAFQ.js → update-scenario-NEXCAQI7.js} +5 -4
- package/dist/{update-suite-JJTNOTXN.js → update-suite-SNO3GS7T.js} +5 -4
- package/package.json +20 -18
- package/pyproject.toml +10 -2
- package/src/__tests__/all-tools.integration.test.ts +56 -120
- package/src/__tests__/config.unit.test.ts +33 -0
- package/src/__tests__/docker.integration.test.ts +91 -7
- package/src/__tests__/documentation-fetch-security.integration.test.ts +182 -0
- package/src/__tests__/feature-map-tool-drift.unit.test.ts +93 -0
- package/src/__tests__/http-security-origin.unit.test.ts +97 -0
- package/src/__tests__/http-security-sessions.unit.test.ts +209 -0
- package/src/__tests__/http-security-verifier.unit.test.ts +248 -0
- package/src/__tests__/http-server-allocation.integration.test.ts +279 -0
- package/src/__tests__/http-server-origin.integration.test.ts +169 -0
- package/src/__tests__/http-server-session-auth.integration.test.ts +341 -0
- package/src/__tests__/http-transport.integration.test.ts +85 -87
- package/src/__tests__/langwatch-api.unit.test.ts +206 -0
- package/src/__tests__/public-http-request-security.integration.test.ts +118 -0
- package/src/__tests__/report-issue.integration.test.ts +147 -0
- package/src/__tests__/support/http-server-harness.ts +138 -0
- package/src/__tests__/tools.unit.test.ts +24 -0
- package/src/config.ts +17 -2
- package/src/create-mcp-server.ts +43 -29
- package/src/documentation-fetch.ts +111 -0
- package/src/http-security.ts +492 -0
- package/src/http-server.ts +536 -152
- package/src/index.ts +49 -2
- package/src/langwatch-api-agents.ts +2 -2
- package/src/langwatch-api.ts +138 -8
- package/src/public-http-request.ts +242 -0
- package/src/tools/discover-evaluator-schema.ts +2 -2
- package/src/tools/get-trace.ts +2 -12
- package/src/tools/report-issue.ts +150 -0
- package/src/tools/search-traces.ts +4 -0
- package/src/utils/format-evaluations.ts +20 -0
- package/tsconfig.json +8 -1
- package/uv.lock +261 -207
- package/dist/chunk-IUP5IHBD.js +0 -71
- package/dist/discover-evaluator-schema-H23XCLNE.js +0 -1402
- package/dist/http-server-GXVEDA7G.js +0 -226
- package/dist/langwatch-api-workflows-CXCTTCXT.js +0 -15
- package/pnpm-workspace.yaml +0 -17
package/src/index.ts
CHANGED
|
@@ -24,6 +24,17 @@ const argv = await yargs(hideBin(process.argv))
|
|
|
24
24
|
description: "HTTP server port (only used with --http)",
|
|
25
25
|
default: 3000,
|
|
26
26
|
})
|
|
27
|
+
.option("host", {
|
|
28
|
+
type: "string",
|
|
29
|
+
description:
|
|
30
|
+
"HTTP listen address (only used with --http). Defaults to loopback; pass 0.0.0.0 to accept connections from other machines",
|
|
31
|
+
})
|
|
32
|
+
.option("allowedOrigin", {
|
|
33
|
+
type: "array",
|
|
34
|
+
string: true,
|
|
35
|
+
description:
|
|
36
|
+
"Browser origin allowed to call the HTTP server, repeatable. Loopback origins are always allowed",
|
|
37
|
+
})
|
|
27
38
|
.help()
|
|
28
39
|
.parseAsync();
|
|
29
40
|
|
|
@@ -34,11 +45,47 @@ initConfig({
|
|
|
34
45
|
|
|
35
46
|
if (argv.http) {
|
|
36
47
|
const { startHttpServer } = await import("./http-server.js");
|
|
37
|
-
const {
|
|
38
|
-
|
|
48
|
+
const { isLoopbackHost, parseAllowedOrigins } = await import(
|
|
49
|
+
"./http-security.js"
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const allowedOrigins =
|
|
53
|
+
argv.allowedOrigin && argv.allowedOrigin.length > 0
|
|
54
|
+
? parseAllowedOrigins(argv.allowedOrigin.join(","))
|
|
55
|
+
: undefined;
|
|
56
|
+
|
|
57
|
+
const { port, host } = await startHttpServer({
|
|
58
|
+
port: argv.port,
|
|
59
|
+
host: argv.host,
|
|
60
|
+
allowedOrigins,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// An IPv6 literal has to be bracketed inside a URL, and must not be
|
|
64
|
+
// double-bracketed when the operator already wrote it that way.
|
|
65
|
+
const displayHost =
|
|
66
|
+
host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
67
|
+
console.log(
|
|
68
|
+
`LangWatch MCP server listening on http://${displayHost}:${port}/mcp`
|
|
69
|
+
);
|
|
39
70
|
console.log(
|
|
40
71
|
"Clients must provide their API key via Authorization: Bearer <key> header"
|
|
41
72
|
);
|
|
73
|
+
if (!isLoopbackHost(host)) {
|
|
74
|
+
console.warn(
|
|
75
|
+
`Warning: bound to ${host}, so the server is reachable from other machines. ` +
|
|
76
|
+
"Make sure it sits behind a trusted network boundary and that --allowedOrigin " +
|
|
77
|
+
"lists only origins you control."
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (process.env.LANGWATCH_MCP_TRUST_PROXY === "true") {
|
|
81
|
+
console.warn(
|
|
82
|
+
"Warning: LANGWATCH_MCP_TRUST_PROXY=true, so the rate limit on failed " +
|
|
83
|
+
"authentication uses the client address from X-Forwarded-For. Keep this " +
|
|
84
|
+
"on only when a trusted proxy terminates in front of the port and " +
|
|
85
|
+
"overwrites that header. Otherwise a client can supply any address there " +
|
|
86
|
+
"and the limit stops counting."
|
|
87
|
+
);
|
|
88
|
+
}
|
|
42
89
|
} else {
|
|
43
90
|
const transport = new StdioServerTransport();
|
|
44
91
|
const server = createMcpServer();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { makeRequest } from "./langwatch-api.js";
|
|
2
|
+
import { requestPublicJson } from "./public-http-request.js";
|
|
2
3
|
|
|
3
4
|
export interface AgentSummary {
|
|
4
5
|
id: string;
|
|
@@ -76,13 +77,12 @@ export async function runAgent(
|
|
|
76
77
|
throw new Error("HTTP agent has no URL configured");
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
const
|
|
80
|
+
const result = await requestPublicJson(url, {
|
|
80
81
|
method: "POST",
|
|
81
82
|
headers: { "Content-Type": "application/json" },
|
|
82
83
|
body: JSON.stringify(input ?? {}),
|
|
83
84
|
});
|
|
84
85
|
|
|
85
|
-
const result = (await response.json()) as Record<string, unknown>;
|
|
86
86
|
return { agentType: "http", result };
|
|
87
87
|
}
|
|
88
88
|
|
package/src/langwatch-api.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HandledErrorFault,
|
|
3
|
+
SerializedReason,
|
|
4
|
+
} from "@langwatch/handled-error";
|
|
1
5
|
import { getConfig, requireApiKey } from "./config.js";
|
|
6
|
+
import type { EvaluationSummary } from "./utils/format-evaluations.js";
|
|
2
7
|
|
|
3
8
|
// --- Response types ---
|
|
4
9
|
|
|
@@ -10,6 +15,7 @@ export interface TraceSearchResult {
|
|
|
10
15
|
timestamps?: { started_at?: string | number };
|
|
11
16
|
metadata?: Record<string, unknown>;
|
|
12
17
|
error?: Record<string, unknown>;
|
|
18
|
+
evaluations?: EvaluationSummary[];
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export interface SearchTracesResponse {
|
|
@@ -39,13 +45,7 @@ export interface TraceDetailResponse {
|
|
|
39
45
|
};
|
|
40
46
|
error?: Record<string, unknown>;
|
|
41
47
|
ascii_tree?: string;
|
|
42
|
-
evaluations?:
|
|
43
|
-
evaluator_id?: string;
|
|
44
|
-
name?: string;
|
|
45
|
-
score?: number;
|
|
46
|
-
passed?: boolean;
|
|
47
|
-
label?: string;
|
|
48
|
-
}>;
|
|
48
|
+
evaluations?: EvaluationSummary[];
|
|
49
49
|
spans?: Array<{
|
|
50
50
|
span_id: string;
|
|
51
51
|
name?: string;
|
|
@@ -106,14 +106,121 @@ export interface PromptMutationResponse {
|
|
|
106
106
|
// --- HTTP client ---
|
|
107
107
|
|
|
108
108
|
export class LangWatchApiError extends Error {
|
|
109
|
+
readonly code?: string;
|
|
110
|
+
readonly tips?: string[];
|
|
111
|
+
readonly docsUrl?: string;
|
|
112
|
+
readonly fault?: HandledErrorFault;
|
|
113
|
+
/**
|
|
114
|
+
* The per-field failures behind this error, verbatim from the envelope.
|
|
115
|
+
* A validation failure carries the offending field and the values it would
|
|
116
|
+
* have accepted here — the difference between an agent correcting its own
|
|
117
|
+
* request and an agent guessing again.
|
|
118
|
+
*/
|
|
119
|
+
readonly reasons?: SerializedReason[];
|
|
120
|
+
|
|
109
121
|
constructor(
|
|
110
122
|
message: string,
|
|
111
123
|
public readonly status: number,
|
|
112
124
|
public readonly responseBody: string,
|
|
125
|
+
options: {
|
|
126
|
+
code?: string;
|
|
127
|
+
tips?: string[];
|
|
128
|
+
docsUrl?: string;
|
|
129
|
+
fault?: HandledErrorFault;
|
|
130
|
+
reasons?: SerializedReason[];
|
|
131
|
+
} = {},
|
|
113
132
|
) {
|
|
114
133
|
super(message);
|
|
115
134
|
this.name = "LangWatchApiError";
|
|
135
|
+
this.code = options.code;
|
|
136
|
+
this.tips = options.tips;
|
|
137
|
+
this.docsUrl = options.docsUrl;
|
|
138
|
+
this.fault = options.fault;
|
|
139
|
+
this.reasons = options.reasons;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Structured fields extracted from a handled-error JSON response body. */
|
|
144
|
+
interface ParsedErrorBody {
|
|
145
|
+
code?: string;
|
|
146
|
+
message?: string;
|
|
147
|
+
tips?: string[];
|
|
148
|
+
docsUrl?: string;
|
|
149
|
+
fault?: HandledErrorFault;
|
|
150
|
+
reasons?: SerializedReason[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const VALID_FAULTS: readonly HandledErrorFault[] = ["customer", "platform", "provider"];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parses an error response body as a handled-error envelope. Accepts both the
|
|
157
|
+
* REST shape (`{ error: "<code>", message, tips?, docsUrl?, fault? }`) and the
|
|
158
|
+
* serialized tRPC shape (`{ code, message?, tips?, docsUrl?, fault?, ... }`).
|
|
159
|
+
* Returns an empty object when the body is not a recognizable error envelope.
|
|
160
|
+
*/
|
|
161
|
+
function parseErrorBody(responseBody: string): ParsedErrorBody {
|
|
162
|
+
try {
|
|
163
|
+
const parsed: unknown = JSON.parse(responseBody);
|
|
164
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
167
|
+
const body = parsed as Record<string, unknown>;
|
|
168
|
+
// Prefer `code` (the domain discriminant) over `error` — the
|
|
169
|
+
// packages/api unversioned envelope uses `error` for the HTTP status
|
|
170
|
+
// text ("Not Found") while `code` holds the real code.
|
|
171
|
+
const code =
|
|
172
|
+
typeof body.code === "string"
|
|
173
|
+
? body.code
|
|
174
|
+
: typeof body.error === "string"
|
|
175
|
+
? body.error
|
|
176
|
+
: undefined;
|
|
177
|
+
const message = typeof body.message === "string" ? body.message : undefined;
|
|
178
|
+
if (code === undefined && message === undefined) {
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
const tips =
|
|
182
|
+
Array.isArray(body.tips) && body.tips.every((t) => typeof t === "string")
|
|
183
|
+
? (body.tips as string[])
|
|
184
|
+
: undefined;
|
|
185
|
+
const docsUrl = typeof body.docsUrl === "string" ? body.docsUrl : undefined;
|
|
186
|
+
const fault = VALID_FAULTS.includes(body.fault as HandledErrorFault)
|
|
187
|
+
? (body.fault as HandledErrorFault)
|
|
188
|
+
: undefined;
|
|
189
|
+
const reasons =
|
|
190
|
+
Array.isArray(body.reasons) &&
|
|
191
|
+
body.reasons.every((r) => !!r && typeof r === "object")
|
|
192
|
+
? (body.reasons as SerializedReason[])
|
|
193
|
+
: undefined;
|
|
194
|
+
return { code, message, tips, docsUrl, fault, reasons };
|
|
195
|
+
} catch {
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The human line for one per-field failure: the field, what it would have
|
|
202
|
+
* accepted, and what it got. Returns null when a reason names no field, so a
|
|
203
|
+
* generic nested error adds no noise to the message.
|
|
204
|
+
*
|
|
205
|
+
* This exists because the MCP transport's only channel to the caller is the
|
|
206
|
+
* error MESSAGE — an agent never sees the `reasons` array itself, so a
|
|
207
|
+
* rejection whose remedy lives only there is unfollowable.
|
|
208
|
+
*/
|
|
209
|
+
function describeReason(reason: SerializedReason): string | null {
|
|
210
|
+
const meta = reason.meta;
|
|
211
|
+
if (!meta || typeof meta !== "object") return null;
|
|
212
|
+
|
|
213
|
+
const field = typeof meta.field === "string" ? meta.field : null;
|
|
214
|
+
if (!field) return null;
|
|
215
|
+
|
|
216
|
+
const parts = [`- ${field}`];
|
|
217
|
+
if (meta.received !== undefined) {
|
|
218
|
+
parts.push(`received ${JSON.stringify(meta.received)}`);
|
|
219
|
+
}
|
|
220
|
+
if (Array.isArray(meta.expected) && meta.expected.length > 0) {
|
|
221
|
+
parts.push(`expected one of: ${meta.expected.join(", ")}`);
|
|
116
222
|
}
|
|
223
|
+
return parts.join(" — ");
|
|
117
224
|
}
|
|
118
225
|
|
|
119
226
|
/**
|
|
@@ -151,10 +258,33 @@ export async function makeRequest(
|
|
|
151
258
|
|
|
152
259
|
if (!response.ok) {
|
|
153
260
|
const responseBody = await response.text();
|
|
261
|
+
const parsed = parseErrorBody(responseBody);
|
|
262
|
+
const lines = [
|
|
263
|
+
`LangWatch API error ${response.status}: ${parsed.message ?? responseBody}`,
|
|
264
|
+
];
|
|
265
|
+
const reasonLines = (parsed.reasons ?? [])
|
|
266
|
+
.map(describeReason)
|
|
267
|
+
.filter((line): line is string => line !== null);
|
|
268
|
+
if (reasonLines.length > 0) {
|
|
269
|
+
lines.push("Rejected fields:", ...reasonLines);
|
|
270
|
+
}
|
|
271
|
+
if (parsed.tips && parsed.tips.length > 0) {
|
|
272
|
+
lines.push("Tips:", ...parsed.tips.map((tip) => `- ${tip}`));
|
|
273
|
+
}
|
|
274
|
+
if (parsed.docsUrl) {
|
|
275
|
+
lines.push(`Docs: ${parsed.docsUrl}`);
|
|
276
|
+
}
|
|
154
277
|
throw new LangWatchApiError(
|
|
155
|
-
|
|
278
|
+
lines.join("\n"),
|
|
156
279
|
response.status,
|
|
157
280
|
responseBody,
|
|
281
|
+
{
|
|
282
|
+
code: parsed.code,
|
|
283
|
+
tips: parsed.tips,
|
|
284
|
+
docsUrl: parsed.docsUrl,
|
|
285
|
+
fault: parsed.fault,
|
|
286
|
+
reasons: parsed.reasons,
|
|
287
|
+
},
|
|
158
288
|
);
|
|
159
289
|
}
|
|
160
290
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import { request as httpRequest, type RequestOptions } from "node:http";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
|
|
6
|
+
import { classify } from "@langwatch/ssrf";
|
|
7
|
+
|
|
8
|
+
const MAX_REDIRECTS = 5;
|
|
9
|
+
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
11
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
12
|
+
const SENSITIVE_REDIRECT_HEADERS = new Set([
|
|
13
|
+
"authorization",
|
|
14
|
+
"cookie",
|
|
15
|
+
"cookie2",
|
|
16
|
+
"proxy-authorization",
|
|
17
|
+
"x-api-key",
|
|
18
|
+
"api-key",
|
|
19
|
+
"x-auth-token",
|
|
20
|
+
"x-access-token",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
type DnsResolver = (hostname: string, options: { all: true; verbatim: true }) => Promise<ResolvedAddress[]>;
|
|
24
|
+
|
|
25
|
+
interface ResolvedAddress {
|
|
26
|
+
address: string;
|
|
27
|
+
family: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PublicDestination {
|
|
31
|
+
address: string;
|
|
32
|
+
family: number;
|
|
33
|
+
url: URL;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function unsafeDestinationError(): Error {
|
|
37
|
+
return new Error("HTTP agent destinations must resolve only to globally routable public addresses");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hostnameWithoutBrackets(hostname: string): string {
|
|
41
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertPublicAddress(address: string): void {
|
|
45
|
+
if (classify(address) !== "global") {
|
|
46
|
+
throw unsafeDestinationError();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
51
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
52
|
+
try {
|
|
53
|
+
return await Promise.race([
|
|
54
|
+
promise,
|
|
55
|
+
new Promise<never>((_resolve, reject) => {
|
|
56
|
+
timeout = setTimeout(() => reject(new Error("request timed out")), timeoutMs);
|
|
57
|
+
timeout.unref();
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
} finally {
|
|
61
|
+
if (timeout) {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function headersForRedirect(
|
|
68
|
+
headers: Record<string, string> | undefined,
|
|
69
|
+
crossOrigin: boolean
|
|
70
|
+
): Record<string, string> | undefined {
|
|
71
|
+
if (!headers || !crossOrigin) {
|
|
72
|
+
return headers;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const safeHeaders = Object.fromEntries(
|
|
76
|
+
Object.entries(headers).filter(([name]) => !SENSITIVE_REDIRECT_HEADERS.has(name.toLowerCase()))
|
|
77
|
+
);
|
|
78
|
+
return Object.keys(safeHeaders).length > 0 ? safeHeaders : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function resolvePublicDestination(
|
|
82
|
+
input: string,
|
|
83
|
+
resolver: DnsResolver = dnsLookup
|
|
84
|
+
): Promise<PublicDestination> {
|
|
85
|
+
let url: URL;
|
|
86
|
+
try {
|
|
87
|
+
url = new URL(input);
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error("HTTP agent URL is invalid");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "") {
|
|
93
|
+
throw unsafeDestinationError();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const hostname = hostnameWithoutBrackets(url.hostname);
|
|
97
|
+
const literalFamily = isIP(hostname);
|
|
98
|
+
if (literalFamily !== 0) {
|
|
99
|
+
assertPublicAddress(hostname);
|
|
100
|
+
return { address: hostname, family: literalFamily, url };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let addresses: ResolvedAddress[];
|
|
104
|
+
try {
|
|
105
|
+
addresses = await withTimeout(
|
|
106
|
+
resolver(hostname, { all: true, verbatim: true }),
|
|
107
|
+
REQUEST_TIMEOUT_MS
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Error("HTTP agent destination could not be resolved");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (addresses.length === 0) {
|
|
114
|
+
throw new Error("HTTP agent destination could not be resolved");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const { address } of addresses) {
|
|
118
|
+
assertPublicAddress(address);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const selected = addresses.find(({ family }) => family === 4) ?? addresses[0]!;
|
|
122
|
+
return {
|
|
123
|
+
address: selected.address,
|
|
124
|
+
family: selected.family,
|
|
125
|
+
url,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface PublicJsonRequestOptions {
|
|
130
|
+
body?: string;
|
|
131
|
+
headers?: Record<string, string>;
|
|
132
|
+
method?: "GET" | "POST";
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function requestBody(
|
|
137
|
+
destination: PublicDestination,
|
|
138
|
+
options: PublicJsonRequestOptions
|
|
139
|
+
): Promise<{ body: string; location?: string; statusCode: number }> {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const hostname = hostnameWithoutBrackets(destination.url.hostname);
|
|
142
|
+
const requestOptions: RequestOptions = {
|
|
143
|
+
protocol: destination.url.protocol,
|
|
144
|
+
hostname,
|
|
145
|
+
port: destination.url.port || undefined,
|
|
146
|
+
path: `${destination.url.pathname}${destination.url.search}`,
|
|
147
|
+
method: options.method ?? "GET",
|
|
148
|
+
headers: options.headers,
|
|
149
|
+
family: destination.family,
|
|
150
|
+
signal: options.signal,
|
|
151
|
+
lookup: (_hostname, _options, callback) => {
|
|
152
|
+
callback(null, destination.address, destination.family);
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const transport = destination.url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
156
|
+
const request = transport(requestOptions, (response) => {
|
|
157
|
+
const chunks: Buffer[] = [];
|
|
158
|
+
let receivedBytes = 0;
|
|
159
|
+
response.once("aborted", () => {
|
|
160
|
+
reject(new Error("HTTP agent response was interrupted"));
|
|
161
|
+
});
|
|
162
|
+
response.once("error", reject);
|
|
163
|
+
|
|
164
|
+
const contentLength = Number(response.headers["content-length"]);
|
|
165
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
|
|
166
|
+
response.destroy(new Error("HTTP agent response is too large"));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
response.on("data", (chunk: Buffer) => {
|
|
171
|
+
receivedBytes += chunk.length;
|
|
172
|
+
if (receivedBytes > MAX_RESPONSE_BYTES) {
|
|
173
|
+
response.destroy(new Error("HTTP agent response is too large"));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
chunks.push(chunk);
|
|
177
|
+
});
|
|
178
|
+
response.once("end", () => {
|
|
179
|
+
resolve({
|
|
180
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
181
|
+
location: response.headers.location,
|
|
182
|
+
statusCode: response.statusCode ?? 0,
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
request.once("error", reject);
|
|
188
|
+
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
189
|
+
request.destroy(new Error("HTTP agent request timed out"));
|
|
190
|
+
});
|
|
191
|
+
if (options.body !== undefined) {
|
|
192
|
+
request.write(options.body);
|
|
193
|
+
}
|
|
194
|
+
request.end();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function requestPublicJson(
|
|
199
|
+
input: string,
|
|
200
|
+
options: PublicJsonRequestOptions = {},
|
|
201
|
+
redirectCount = 0
|
|
202
|
+
): Promise<Record<string, unknown>> {
|
|
203
|
+
const destination = await resolvePublicDestination(input);
|
|
204
|
+
const response = await requestBody(destination, options);
|
|
205
|
+
|
|
206
|
+
if (REDIRECT_STATUSES.has(response.statusCode) && response.location) {
|
|
207
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
208
|
+
throw new Error(`HTTP agent returned too many redirects`);
|
|
209
|
+
}
|
|
210
|
+
const redirectUrl = new URL(response.location, destination.url);
|
|
211
|
+
const redirectHeaders = headersForRedirect(
|
|
212
|
+
options.headers,
|
|
213
|
+
redirectUrl.origin !== destination.url.origin
|
|
214
|
+
);
|
|
215
|
+
const preserveBody = response.statusCode === 307 || response.statusCode === 308;
|
|
216
|
+
return requestPublicJson(
|
|
217
|
+
redirectUrl.toString(),
|
|
218
|
+
preserveBody
|
|
219
|
+
? { ...options, headers: redirectHeaders }
|
|
220
|
+
: {
|
|
221
|
+
...options,
|
|
222
|
+
body: undefined,
|
|
223
|
+
headers: redirectHeaders,
|
|
224
|
+
method: "GET",
|
|
225
|
+
},
|
|
226
|
+
redirectCount + 1
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
231
|
+
throw new Error(`HTTP agent returned an unsuccessful status: ${response.statusCode}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let parsed: unknown;
|
|
235
|
+
try {
|
|
236
|
+
parsed = JSON.parse(response.body);
|
|
237
|
+
} catch {
|
|
238
|
+
throw new Error("HTTP agent returned invalid JSON");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return parsed as Record<string, unknown>;
|
|
242
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AVAILABLE_EVALUATORS } from "
|
|
2
|
-
import type { EvaluatorDefinition, EvaluatorTypes } from "
|
|
1
|
+
import { AVAILABLE_EVALUATORS } from "../../../../services/langevals/ts-integration/evaluators.generated.js";
|
|
2
|
+
import type { EvaluatorDefinition, EvaluatorTypes } from "../../../../services/langevals/ts-integration/evaluators.generated.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Formats evaluator schema information for the discover_schema tool.
|
package/src/tools/get-trace.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getTraceById as apiGetTraceById } from "../langwatch-api.js";
|
|
2
|
+
import { formatEvaluationLines } from "../utils/format-evaluations.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Handles the get_trace MCP tool invocation.
|
|
@@ -35,18 +36,7 @@ export async function handleGetTrace(params: {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
if (result.evaluations && result.evaluations.length > 0) {
|
|
38
|
-
lines.push("\n## Evaluations");
|
|
39
|
-
for (const evaluation of result.evaluations) {
|
|
40
|
-
const status =
|
|
41
|
-
evaluation.passed === true
|
|
42
|
-
? "PASSED"
|
|
43
|
-
: evaluation.passed === false
|
|
44
|
-
? "FAILED"
|
|
45
|
-
: "N/A";
|
|
46
|
-
lines.push(
|
|
47
|
-
`- **${evaluation.name || evaluation.evaluator_id}**: ${status}${evaluation.score != null ? ` (score: ${evaluation.score})` : ""}${evaluation.label ? ` [${evaluation.label}]` : ""}`
|
|
48
|
-
);
|
|
49
|
-
}
|
|
39
|
+
lines.push("\n## Evaluations", ...formatEvaluationLines(result.evaluations));
|
|
50
40
|
}
|
|
51
41
|
|
|
52
42
|
if (result.formatted_trace) {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collectSensitiveEnvValues,
|
|
3
|
+
REDACTION_AUDIT_URL,
|
|
4
|
+
redactReportText,
|
|
5
|
+
redactSessionJsonl,
|
|
6
|
+
truncateJsonlToByteBudget,
|
|
7
|
+
} from "@langwatch/redaction";
|
|
8
|
+
import { getConfig } from "../config.js";
|
|
9
|
+
|
|
10
|
+
/** Transcripts are capped after redaction; oldest lines are dropped first. */
|
|
11
|
+
const MAX_SESSION_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
export interface ReportIssueParams {
|
|
15
|
+
user_approved: boolean;
|
|
16
|
+
title: string;
|
|
17
|
+
summary?: string;
|
|
18
|
+
session_content?: string;
|
|
19
|
+
contact_email?: string;
|
|
20
|
+
agent?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RedactedReport {
|
|
24
|
+
title: string;
|
|
25
|
+
summary?: string;
|
|
26
|
+
sessionData?: string;
|
|
27
|
+
sessionTruncated: boolean;
|
|
28
|
+
redactedCount: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Redaction runs locally, before anything leaves the machine. */
|
|
32
|
+
function redactReport(params: ReportIssueParams): RedactedReport {
|
|
33
|
+
const envValues = collectSensitiveEnvValues(process.env);
|
|
34
|
+
let redactedCount = 0;
|
|
35
|
+
|
|
36
|
+
const titleResult = redactReportText({ text: params.title, envValues });
|
|
37
|
+
redactedCount += titleResult.redactedCount;
|
|
38
|
+
|
|
39
|
+
let summary: string | undefined;
|
|
40
|
+
if (params.summary) {
|
|
41
|
+
const result = redactReportText({ text: params.summary, envValues });
|
|
42
|
+
summary = result.text;
|
|
43
|
+
redactedCount += result.redactedCount;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let sessionData: string | undefined;
|
|
47
|
+
let sessionTruncated = false;
|
|
48
|
+
if (params.session_content) {
|
|
49
|
+
const redacted = redactSessionJsonl({
|
|
50
|
+
jsonl: params.session_content,
|
|
51
|
+
envValues,
|
|
52
|
+
});
|
|
53
|
+
redactedCount += redacted.redactedCount;
|
|
54
|
+
const truncated = truncateJsonlToByteBudget({
|
|
55
|
+
jsonl: redacted.text,
|
|
56
|
+
maxBytes: MAX_SESSION_BYTES,
|
|
57
|
+
});
|
|
58
|
+
sessionData = truncated.text;
|
|
59
|
+
sessionTruncated = truncated.truncated;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
title: titleResult.text,
|
|
64
|
+
summary,
|
|
65
|
+
sessionData,
|
|
66
|
+
sessionTruncated,
|
|
67
|
+
redactedCount,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function deliverReport(
|
|
72
|
+
report: RedactedReport,
|
|
73
|
+
params: ReportIssueParams,
|
|
74
|
+
): Promise<string> {
|
|
75
|
+
const config = getConfig();
|
|
76
|
+
const endpoint = config.endpoint.replace(/\/+$/, "");
|
|
77
|
+
|
|
78
|
+
const response = await fetch(`${endpoint}/api/bug-reports`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: {
|
|
81
|
+
"content-type": "application/json",
|
|
82
|
+
...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}),
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
source: "mcp",
|
|
86
|
+
kind: report.sessionData ? "full_session" : "summary",
|
|
87
|
+
title: report.title,
|
|
88
|
+
...(report.summary ? { summary: report.summary } : {}),
|
|
89
|
+
...(report.sessionData
|
|
90
|
+
? { sessionData: report.sessionData, sessionTruncated: report.sessionTruncated }
|
|
91
|
+
: {}),
|
|
92
|
+
...(params.agent ? { agent: params.agent } : {}),
|
|
93
|
+
...(params.contact_email ? { contactEmail: params.contact_email } : {}),
|
|
94
|
+
}),
|
|
95
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
96
|
+
}).catch(() => {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Could not reach ${endpoint} to deliver the report. Retry in a moment; if it ` +
|
|
99
|
+
"keeps failing, email support@langwatch.ai with the same content.",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
const body = (await response.text().catch(() => "")).slice(0, 300);
|
|
105
|
+
throw new Error(
|
|
106
|
+
`The reports endpoint answered HTTP ${response.status}: ${body}. Retry in a ` +
|
|
107
|
+
"moment; if it keeps failing, email support@langwatch.ai.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { id } = (await response.json()) as { id: string };
|
|
112
|
+
return id;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Sends an issue report to the LangWatch team. Deliberately does NOT require
|
|
117
|
+
* an API key: the reporter may be struggling precisely because setup failed.
|
|
118
|
+
* When a key is configured it only links the report to the project.
|
|
119
|
+
*/
|
|
120
|
+
export async function handleReportIssue(
|
|
121
|
+
params: ReportIssueParams,
|
|
122
|
+
): Promise<string> {
|
|
123
|
+
if (!params.user_approved) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
"This sends a report to the LangWatch team, so the user must approve it first. " +
|
|
126
|
+
'Ask the user: "Can I send this issue report to LangWatch to help them fix it?" ' +
|
|
127
|
+
"If they agree, call again with user_approved=true. Secrets and personal data " +
|
|
128
|
+
`are redacted locally before sending; audit the rules at ${REDACTION_AUDIT_URL}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!params.summary?.trim() && !params.session_content?.trim()) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
"Nothing to report: pass a summary (what you tried, verbatim errors, what " +
|
|
135
|
+
"you had to figure out), session_content, or both.",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const report = redactReport(params);
|
|
140
|
+
const id = await deliverReport(report, params);
|
|
141
|
+
|
|
142
|
+
return [
|
|
143
|
+
`Report sent to the LangWatch team (${id}). Thank you!`,
|
|
144
|
+
`Redacted ${report.redactedCount} sensitive value${report.redactedCount === 1 ? "" : "s"} locally before sending.`,
|
|
145
|
+
...(report.sessionTruncated
|
|
146
|
+
? ["The session content was truncated to the most recent activity to fit the upload limit."]
|
|
147
|
+
: []),
|
|
148
|
+
"Reports like this directly shape what gets fixed next.",
|
|
149
|
+
].join(" ");
|
|
150
|
+
}
|