Skip to content

Commit f8e524b

Browse files
[NET-1186] feat: Add file-handling support in simulation workflow (#90)
1 parent dfb1805 commit f8e524b

9 files changed

Lines changed: 225 additions & 15 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "netra-sdk",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"description": "A comprehensive TypeScript/JavaScript SDK for AI application observability built on top of OpenTelemetry and Traceloop",
55
"type": "module",
66
"main": "./dist/index.cjs",

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export type {
111111
ConversationResponse,
112112
ConversationResult,
113113
CreateRunResult,
114+
ProcessedFile,
114115
SimulationItem,
115116
SimulationOptions,
116117
SimulationResult,

src/simulation/api.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { SpanWrapper } from "../span-wrapper";
99
import { SimulationHttpClient } from "./client";
1010
import {
1111
ConversationResult,
12+
FileData,
1213
SimulationItem,
1314
SimulationResult,
1415
} from "./models";
@@ -197,10 +198,11 @@ export class Simulation {
197198
runItem: SimulationItem,
198199
task: BaseTask,
199200
): Promise<ConversationResult> {
200-
const { runItemId, message: initialMessage, turnId: initialTurnId } = runItem;
201+
const { runItemId, message: initialMessage, turnId: initialTurnId, files: initialFiles } = runItem;
201202
let message = initialMessage;
202203
let turnId = initialTurnId;
203204
let sessionId: string | null = null;
205+
let rawFiles: FileData[] = initialFiles ?? [];
204206

205207
while (true) {
206208
const span = new SpanWrapper(SPAN_NAME, {}, LOG_PREFIX);
@@ -209,11 +211,8 @@ export class Simulation {
209211
try {
210212
const traceId = span.getCurrentSpan()?.spanContext().traceId ?? "";
211213

212-
// Run the user's task inside the active span context so child
213-
// spans (LLM calls, HTTP, etc.) are parented correctly and
214-
// outgoing HTTP headers carry the W3C trace context.
215214
const [responseMessage, taskSessionId] = await span.withActive(
216-
() => executeTask(task, message, sessionId),
215+
() => executeTask(task, message, sessionId, rawFiles),
217216
);
218217

219218
if (taskSessionId) {
@@ -252,9 +251,9 @@ export class Simulation {
252251
};
253252
}
254253

255-
// Continue to next turn
256254
message = response.nextUserMessage!;
257255
turnId = response.nextTurnId!;
256+
rawFiles = response.nextFiles || [];
258257
} catch (error) {
259258
const errorMsg = error instanceof Error ? error.message : String(error);
260259
Logger.error(

src/simulation/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Config } from "../config";
33
import { Logger } from "../logger";
44
import { injectTraceContextHeaders } from "../utils/context-propagation";
55
import { ConversationResponse, SimulationItem } from "./models";
6+
import { parseFiles } from "./utils";
67

78
const LOG_PREFIX = "netra.simulation";
89
const DEFAULT_TIMEOUT = 10000; // 10 seconds in milliseconds
@@ -144,6 +145,7 @@ export class SimulationHttpClient {
144145
runItemId: msg.testRunItemId || "",
145146
message: msg.userMessage || "",
146147
turnId: msg.turnId || "",
148+
files: parseFiles(msg.attachments),
147149
}),
148150
);
149151

@@ -206,6 +208,7 @@ export class SimulationHttpClient {
206208
nextTurnId: nextMsg.turnId || "",
207209
nextUserMessage: nextMsg.userMessage || "",
208210
nextRunItemId: nextMsg.testRunItemId || "",
211+
nextFiles: parseFiles(nextMsg.attachments),
209212
};
210213
} catch (error) {
211214
const errorMsg = this._extractErrorMessage(error);

src/simulation/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
ConversationStatus,
1010
type ConversationResponse,
1111
type ConversationResult,
12+
type ProcessedFile,
1213
type SimulationItem,
1314
type SimulationResult,
1415
type TaskResult,

src/simulation/models.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,42 @@ export enum ConversationStatus {
66
STOP = "stop",
77
}
88

9+
/**
10+
* Raw file metadata received from the backend.
11+
*
12+
* Contains a pre-signed download URL that the SDK uses to fetch the actual
13+
* file content. Instances are produced by parsing the `attachments` array
14+
* returned on each user message from the simulation API.
15+
*/
16+
export interface FileData {
17+
fileName: string;
18+
contentType: string;
19+
description?: string;
20+
downloadUrl: string;
21+
}
22+
23+
/**
24+
* File after download and base64 encoding, delivered to the user task.
25+
*
26+
* The `data` field contains the raw file bytes encoded as a base64 ASCII
27+
* string. Consumers should decode with `Buffer.from(data, "base64")` before
28+
* passing to an LLM or other downstream system.
29+
*/
30+
export interface ProcessedFile {
31+
fileName: string;
32+
contentType: string;
33+
description?: string;
34+
data: string;
35+
}
36+
937
/**
1038
* Represents a single item in a simulation run.
1139
*/
1240
export interface SimulationItem {
1341
runItemId: string;
1442
message: string;
1543
turnId: string;
44+
files?: FileData[];
1645
}
1746

1847
/**
@@ -24,6 +53,7 @@ export interface ConversationResponse {
2453
nextTurnId?: string;
2554
nextUserMessage?: string;
2655
nextRunItemId?: string;
56+
nextFiles?: FileData[];
2757
}
2858

2959
/**

src/simulation/task.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1-
import { TaskResult } from "./models";
1+
import { ProcessedFile, TaskResult } from "./models";
22

33

44
export abstract class BaseTask {
55
/**
6+
* Process a simulation turn and return the agent's response.
7+
*
68
* @param message - The input message from the simulation.
7-
* @param sessionId - The Session identifier.
9+
* @param sessionId - The session identifier.
10+
* @param files - Optional list of base64-encoded file attachments from the
11+
* dataset item. Will be `null` when no files are attached.
812
* @returns The task result containing:
913
* - message (string): The response message from the task.
1014
* - sessionId (string): The session identifier.
1115
*/
1216
abstract run(
1317
message: string,
1418
sessionId?: string | null,
19+
files?: ProcessedFile[] | null,
1520
): Promise<TaskResult> | TaskResult;
1621
}

src/simulation/utils.ts

Lines changed: 175 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1+
import axios from "axios";
2+
import pLimit from "p-limit";
13
import { Logger } from "../logger";
4+
import { FileData, ProcessedFile } from "./models";
25
import { BaseTask } from "./task";
36

7+
type LimitFunction = ReturnType<typeof pLimit>;
8+
49
const LOG_PREFIX = "netra.simulation";
10+
const DEFAULT_FILE_DOWNLOAD_TIMEOUT_S = 30;
11+
const MAX_FILE_DOWNLOAD_WORKERS = 8;
12+
13+
let _cachedFileDownloadTimeoutMs: number | null = null;
514

615
/**
716
* Format the trace ID as a 32-digit hexadecimal string.
@@ -35,26 +44,188 @@ export function validateSimulationInputs(
3544
return true;
3645
}
3746

47+
/**
48+
* Get the file download timeout in milliseconds.
49+
*
50+
* Reads from the `NETRA_SIMULATION_FILE_DOWNLOAD_TIMEOUT` environment
51+
* variable (value in seconds) on first access and caches the result.
52+
* Falls back to the default of 30 s.
53+
*
54+
* @returns Timeout in milliseconds
55+
*/
56+
function _getFileDownloadTimeout(): number {
57+
if (_cachedFileDownloadTimeoutMs !== null) {
58+
return _cachedFileDownloadTimeoutMs;
59+
}
60+
61+
const envVal = process.env.NETRA_SIMULATION_FILE_DOWNLOAD_TIMEOUT;
62+
if (envVal) {
63+
const parsed = parseFloat(envVal);
64+
if (!isNaN(parsed) && parsed > 0) {
65+
_cachedFileDownloadTimeoutMs = parsed * 1000;
66+
return _cachedFileDownloadTimeoutMs;
67+
}
68+
Logger.warn(
69+
`${LOG_PREFIX}: Invalid file download timeout '${envVal}', using default ${DEFAULT_FILE_DOWNLOAD_TIMEOUT_S}s`,
70+
);
71+
}
72+
_cachedFileDownloadTimeoutMs = DEFAULT_FILE_DOWNLOAD_TIMEOUT_S * 1000;
73+
return _cachedFileDownloadTimeoutMs;
74+
}
75+
76+
/**
77+
* Download a single file from its pre-signed URL and base64-encode the content.
78+
*
79+
* @param fileData - Metadata with the download URL
80+
* @param timeoutMs - Request timeout in milliseconds
81+
* @param signal - AbortSignal to cancel the download if another file in the
82+
* batch fails
83+
* @returns ProcessedFile with base64-encoded data
84+
* @throws Error if the download fails, with contextual information about which
85+
* file failed and the HTTP status (if available)
86+
*/
87+
async function _downloadSingleFile(
88+
fileData: FileData,
89+
timeoutMs: number,
90+
signal?: AbortSignal,
91+
): Promise<ProcessedFile> {
92+
try {
93+
const response = await axios.get(fileData.downloadUrl, {
94+
responseType: "arraybuffer",
95+
timeout: timeoutMs,
96+
signal,
97+
});
98+
99+
const encoded = Buffer.from(response.data).toString("base64");
100+
return {
101+
fileName: fileData.fileName,
102+
contentType: fileData.contentType,
103+
description: fileData.description,
104+
data: encoded,
105+
};
106+
} catch (error) {
107+
if (axios.isCancel(error)) {
108+
throw new Error(
109+
`Download of '${fileData.fileName}' was cancelled`,
110+
);
111+
}
112+
113+
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
114+
const reason = status
115+
? `HTTP ${status}`
116+
: (error instanceof Error ? error.message : String(error));
117+
118+
Logger.error(
119+
`${LOG_PREFIX}: Failed to download file '${fileData.fileName}': ${reason}`,
120+
);
121+
122+
throw new Error(
123+
`Failed to download file '${fileData.fileName}': ${reason}`,
124+
);
125+
}
126+
}
127+
128+
/**
129+
* Parse raw attachment entries from the backend into typed FileData objects.
130+
*
131+
* Entries missing a `fileName` or `downloadUrl` are skipped with a warning.
132+
*
133+
* @param rawFiles - Array of attachment objects from the API response
134+
* @returns Parsed FileData array (empty when input is null/undefined)
135+
*/
136+
export function parseFiles(
137+
rawFiles: Array<Record<string, string>> | null | undefined,
138+
): FileData[] {
139+
if (!rawFiles) {
140+
return [];
141+
}
142+
143+
const parsed: FileData[] = [];
144+
for (const entry of rawFiles) {
145+
const fileName = entry.fileName || "";
146+
const downloadUrl = entry.downloadUrl || "";
147+
148+
if (!fileName || !downloadUrl) {
149+
Logger.warn(
150+
`${LOG_PREFIX}: Skipping malformed file attachment (missing fileName or downloadUrl)`,
151+
);
152+
continue;
153+
}
154+
155+
parsed.push({
156+
fileName,
157+
contentType: entry.contentType || "",
158+
description: entry.description || undefined,
159+
downloadUrl,
160+
});
161+
}
162+
163+
return parsed;
164+
}
165+
166+
/**
167+
* Download and base64-encode a batch of files concurrently.
168+
*
169+
* Uses up to {@link MAX_FILE_DOWNLOAD_WORKERS} parallel downloads. If any
170+
* single download fails, outstanding downloads are cancelled via
171+
* AbortController and the error propagates to the caller.
172+
*
173+
* @param files - Array of FileData objects to download
174+
* @returns Array of ProcessedFile objects with base64-encoded data, or null
175+
* when the input array is empty / undefined
176+
* @throws Error if any file download fails
177+
*/
178+
export async function processFiles(
179+
files: FileData[] | null | undefined,
180+
): Promise<ProcessedFile[] | null> {
181+
if (!files || files.length === 0) {
182+
return null;
183+
}
184+
185+
const timeoutMs = _getFileDownloadTimeout();
186+
const limit: LimitFunction = pLimit(MAX_FILE_DOWNLOAD_WORKERS);
187+
const controller = new AbortController();
188+
189+
const downloadPromises = files.map((file) =>
190+
limit(() => _downloadSingleFile(file, timeoutMs, controller.signal)),
191+
);
192+
193+
try {
194+
return await Promise.all(downloadPromises);
195+
} catch (error) {
196+
controller.abort();
197+
limit.clearQueue();
198+
throw error;
199+
}
200+
}
201+
38202
/**
39203
* Execute a task function (sync or async) and extract message and session_id.
40204
*
205+
* When `rawFiles` is provided, the files are downloaded and base64-encoded
206+
* before being passed to the task's `run` method.
207+
*
41208
* @param task - The task function to execute
42209
* @param message - The input message to pass to the task
43210
* @param sessionId - The current session identifier
211+
* @param rawFiles - Optional array of file metadata to download and forward
44212
* @returns A tuple of [response_message, session_id]
45-
* @throws Error if the task returns an unsupported type
213+
* @throws Error if the task returns an unsupported type or file download fails
46214
*/
47215
export async function executeTask(
48216
task: BaseTask,
49217
message: string,
50218
sessionId: string | null,
219+
rawFiles?: FileData[] | null,
51220
): Promise<[string, string | null]> {
52-
const result = task.run(message, sessionId);
221+
const processedFiles = rawFiles && rawFiles.length > 0
222+
? await processFiles(rawFiles)
223+
: null;
224+
225+
const result = task.run(message, sessionId, processedFiles);
53226

54-
// Check if result is a Promise (async function)
55227
const resolvedResult = result instanceof Promise ? await result : result;
56228

57-
// Validate that the result is a TaskResult
58229
if (
59230
typeof resolvedResult === "object" &&
60231
resolvedResult !== null &&

0 commit comments

Comments
 (0)