langsmith
Version:
Client library to connect to the LangSmith Observability and Evaluation Platform.
355 lines (354 loc) • 12.4 kB
JavaScript
;
/**
* WebSocket-based command execution for long-running commands.
*
* Uses the `ws` npm package (optional peer dependency).
* Install with: npm install ws
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.WSStreamControl = void 0;
exports.buildWsUrl = buildWsUrl;
exports.buildAuthHeaders = buildAuthHeaders;
exports.raiseForWsError = raiseForWsError;
exports.runWsStream = runWsStream;
exports.reconnectWsStream = reconnectWsStream;
const errors_js_1 = require("./errors.cjs");
async function ensureWs() {
try {
const ws = await import("ws");
return { WebSocket: ws.default || ws.WebSocket || ws };
}
catch {
throw new Error("WebSocket-based execution requires the 'ws' package. " +
"Install it with: npm install ws");
}
}
// =============================================================================
// URL and Auth Helpers
// =============================================================================
/**
* Convert a dataplane HTTP URL to a WebSocket URL for /execute/ws.
*/
function buildWsUrl(dataplaneUrl) {
const wsUrl = dataplaneUrl
.replace("https://", "wss://")
.replace("http://", "ws://");
return `${wsUrl}/execute/ws`;
}
/**
* Build auth headers for the WebSocket upgrade request.
*/
function buildAuthHeaders(apiKey, extraHeaders) {
const headers = {};
if (apiKey) {
headers["X-Api-Key"] = apiKey;
}
if (extraHeaders) {
Object.assign(headers, extraHeaders);
}
return headers;
}
// =============================================================================
// Stream Control
// =============================================================================
/**
* Control interface for an active WebSocket stream.
*
* Created before the async generator starts, bound to the WebSocket once
* the connection opens. The CommandHandle holds a reference to this
* object to send kill/input messages.
*/
class WSStreamControl {
constructor() {
Object.defineProperty(this, "_ws", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "_closed", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "_killed", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
}
/** Bind to the active WebSocket. Called inside the generator. */
_bind(ws) {
this._ws = ws;
}
/** Mark as closed. Called when the generator exits. */
_unbind() {
this._closed = true;
this._ws = null;
}
/** True if kill() has been called on this stream. */
get killed() {
return this._killed;
}
/** Send a kill message to abort the running command. */
sendKill() {
this._killed = true;
if (this._ws && !this._closed && this._ws.readyState === 1) {
this._ws.send(JSON.stringify({ type: "kill" }));
}
}
/** Send stdin data to the running command. */
sendInput(data) {
if (this._ws && !this._closed && this._ws.readyState === 1) {
this._ws.send(JSON.stringify({ type: "input", data }));
}
}
}
exports.WSStreamControl = WSStreamControl;
// =============================================================================
// Error Handling
// =============================================================================
/**
* Raise the appropriate exception from a server error message.
*/
function raiseForWsError(msg, commandId = "") {
const errorType = msg.error_type ?? "CommandError";
const errorMsg = msg.error ?? "Unknown error";
if (errorType === "CommandTimeout") {
throw new errors_js_1.LangSmithCommandTimeoutError(errorMsg);
}
if (errorType === "CommandNotFound") {
throw new errors_js_1.LangSmithSandboxOperationError(commandId ? `Command not found: ${commandId}` : errorMsg, commandId ? "reconnect" : "command", errorType);
}
if (errorType === "SessionExpired") {
throw new errors_js_1.LangSmithSandboxOperationError(commandId ? `Session expired: ${commandId}` : errorMsg, commandId ? "reconnect" : "command", errorType);
}
throw new errors_js_1.LangSmithSandboxOperationError(errorMsg, commandId ? "reconnect" : "command", errorType);
}
// =============================================================================
// WebSocket Stream Helpers
// =============================================================================
/**
* Create a ws WebSocket connection and return a promise that resolves when open
* or rejects on error.
*/
async function connectWs(url, headers) {
const { WebSocket: WS } = await ensureWs();
return new Promise((resolve, reject) => {
const ws = new WS(url, { headers });
ws.on("open", () => {
ws.removeAllListeners("error");
resolve(ws);
});
ws.on("error", (err) => {
ws.removeAllListeners("open");
reject(new errors_js_1.LangSmithSandboxConnectionError(`Failed to connect to sandbox WebSocket: ${err.message}`));
});
});
}
/**
* Read messages from a ws WebSocket as an async iterable.
*
* Yields parsed WsMessage objects. Handles close events and errors,
* mapping them to appropriate exceptions.
*/
async function* readWsMessages(ws) {
// Buffer incoming messages so the consumer can process them at its own pace
const messageQueue = [];
let resolve = null;
let error = null;
let done = false;
const onMessage = (data) => {
const raw = typeof data === "string" ? data : data.toString();
const msg = JSON.parse(raw);
messageQueue.push(msg);
if (resolve) {
const r = resolve;
resolve = null;
r();
}
};
const onClose = (code, reason) => {
done = true;
if (code === 1001) {
error = new errors_js_1.LangSmithSandboxServerReloadError("Server is reloading, reconnect to resume");
}
else if (code !== 1000) {
error = new errors_js_1.LangSmithSandboxConnectionError(`WebSocket connection closed unexpectedly (code: ${code}, reason: ${reason.toString()})`);
}
if (resolve) {
const r = resolve;
resolve = null;
r();
}
};
const onError = (err) => {
done = true;
if (!error) {
error = new errors_js_1.LangSmithSandboxConnectionError(`WebSocket connection error: ${err.message}`);
}
if (resolve) {
const r = resolve;
resolve = null;
r();
}
};
ws.on("message", onMessage);
ws.on("close", onClose);
ws.on("error", onError);
try {
while (true) {
// Drain buffered messages first
while (messageQueue.length > 0) {
yield messageQueue.shift();
}
// If done and queue is empty, we're finished
if (done) {
if (error) {
throw error;
}
return;
}
// Wait for next message or close/error
await new Promise((r) => {
resolve = r;
});
}
}
finally {
ws.removeListener("message", onMessage);
ws.removeListener("close", onClose);
ws.removeListener("error", onError);
}
}
// =============================================================================
// Async Stream Functions
// =============================================================================
/**
* Execute a command over WebSocket, yielding raw message dicts.
*
* Returns a tuple of [async_message_iterator, control]. The control object
* provides sendKill() and sendInput() methods for the CommandHandle.
*
* The iterator yields WsMessage objects with a "type" field:
* - { type: "started", command_id: "...", pid: N }
* - { type: "stdout", data: "...", offset: N }
* - { type: "stderr", data: "...", offset: N }
* - { type: "exit", exit_code: N }
*
* If onStdout/onStderr callbacks are provided, they are invoked as
* data arrives in addition to yielding the messages.
*/
async function runWsStream(dataplaneUrl, apiKey, command, options = {}) {
const { timeout = 60, env, cwd, shell = "/bin/bash", onStdout, onStderr, commandId, idleTimeout = 300, killOnDisconnect = false, ttlSeconds = 600, pty, headers: extraHeaders, } = options;
const wsUrl = buildWsUrl(dataplaneUrl);
const headers = buildAuthHeaders(apiKey, extraHeaders);
const control = new WSStreamControl();
async function* stream() {
let ws;
try {
ws = await connectWs(wsUrl, headers);
control._bind(ws);
// Send execute request
const payload = {
type: "execute",
command,
timeout_seconds: timeout,
shell,
idle_timeout_seconds: idleTimeout,
kill_on_disconnect: killOnDisconnect,
ttl_seconds: ttlSeconds,
};
if (env)
payload.env = env;
if (cwd)
payload.cwd = cwd;
if (commandId)
payload.command_id = commandId;
if (pty)
payload.pty = true;
ws.send(JSON.stringify(payload));
// Read messages until exit or error
for await (const msg of readWsMessages(ws)) {
const msgType = msg.type;
if (msgType === "started") {
yield msg;
}
else if (msgType === "stdout") {
if (onStdout)
onStdout(msg.data);
yield msg;
}
else if (msgType === "stderr") {
if (onStderr)
onStderr(msg.data);
yield msg;
}
else if (msgType === "exit") {
yield msg;
return;
}
else if (msgType === "error") {
raiseForWsError(msg);
}
}
}
finally {
control._unbind();
if (ws && ws.readyState === 1) {
ws.close();
}
}
}
return [stream(), control];
}
/**
* Reconnect to an existing command over WebSocket.
*
* Returns a tuple of [async_message_iterator, control], same as runWsStream.
* The iterator yields stdout, stderr, exit, and error messages.
* No 'started' message is sent on reconnection.
*/
async function reconnectWsStream(dataplaneUrl, apiKey, commandId, options = {}) {
const { stdoutOffset = 0, stderrOffset = 0, headers: extraHeaders } = options;
const wsUrl = buildWsUrl(dataplaneUrl);
const headers = buildAuthHeaders(apiKey, extraHeaders);
const control = new WSStreamControl();
async function* stream() {
let ws;
try {
ws = await connectWs(wsUrl, headers);
control._bind(ws);
// Send reconnect request
ws.send(JSON.stringify({
type: "reconnect",
command_id: commandId,
stdout_offset: stdoutOffset,
stderr_offset: stderrOffset,
}));
// Read messages until exit or error
for await (const msg of readWsMessages(ws)) {
const msgType = msg.type;
if (msgType === "stdout" || msgType === "stderr") {
yield msg;
}
else if (msgType === "exit") {
yield msg;
return;
}
else if (msgType === "error") {
raiseForWsError(msg, commandId);
}
}
}
finally {
control._unbind();
if (ws && ws.readyState === 1) {
ws.close();
}
}
}
return [stream(), control];
}