@stackfactor/agent-utils 1.0.11 → 1.0.13

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.
@@ -1 +1 @@
1
- {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";wBAoaQ,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,qBACO,QAAQ,GAAG,IAAI,eACrB,MAAM,eACN,MAAM,KACjB,GAAG;sBAyCG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;wCAyqBF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,KACpB,GAAG;oCA5hBO,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,KACX,OAAO,CAAC,GAAG,CAAC;sDAi1BF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CAj5B8B,GAAG,KAAG,MAAM;;AA48BzD,wBAOE"}
1
+ {"version":3,"file":"langChain.d.ts","sourceRoot":"","sources":["../../src/langChain.ts"],"names":[],"mappings":";wBAmSQ,MAAM,aACD,MAAM,gBACH,MAAM,SACb,GAAG,EAAE,kBACI,GAAG,UACX,GAAG,KACV,GAAG;sBAyBG,GAAG,UACF,MAAM,UACN,GAAG,eACC,QAAQ,GAAG,IAAI,KAC1B,OAAO,CAAC,GAAG,CAAC;wCAmfF,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,KACpB,GAAG;oCAxYO,MAAM,UACT,GAAG,UACH,GAAG,oBACO,GAAG,eACT,MAAM,eACN,MAAM,wBACG,OAAO,WACpB,GAAG,cACA,MAAM,UACV,GAAG,EAAE,KACX,OAAO,CAAC,GAAG,CAAC;sDA6rBF,MAAM,UACT,GAAG,UACH,MAAM,YACL,GAAG,KACX,OAAO,CAAC,GAAG,CAAC;0CA7vB8B,GAAG,KAAG,MAAM;;AAwzBzD,wBAOE"}
@@ -7,34 +7,7 @@ import constants from "./const.js";
7
7
  import langChain from "langchain";
8
8
  import errorHandlingHelper from "./errorHandling.js";
9
9
  import logger from "./logger.js";
10
- import { z } from "zod";
11
10
  import { zodToJsonSchema } from "zod-to-json-schema";
12
- const NDJSON_SYSTEM_PROMPT = `
13
- You are a streaming JSON generator.
14
-
15
- You MUST output NDJSON (newline-delimited JSON).
16
- That means: ONE valid JSON object per line.
17
-
18
- Allowed event types:
19
- - "progress": short status update for the user
20
- - "final": final complete result
21
-
22
- Rules:
23
- - Output ONLY NDJSON lines (no markdown, no extra text).
24
- - Each line must be valid JSON.
25
- - "progress" must include: type, message, pct (with values between {minPercent} and {maxPercent})
26
- - "final" must include: type, content
27
- - End with EXACTLY ONE "final" event.
28
- - Do NOT reveal hidden chain-of-thought.
29
-
30
- CRITICAL - Escape special characters in ALL string values:
31
- - Newlines must be written as \\n (two characters: backslash + n)
32
- - Tabs must be written as \\t (two characters: backslash + t)
33
- - Carriage returns must be written as \\r (two characters: backslash + r)
34
- - Double quotes inside strings must be written as \\"
35
- - Backslashes must be written as \\\\
36
- This ensures valid JSON output.
37
- `.trim();
38
11
  const JSON_ESCAPE_INSTRUCTION = `
39
12
  CRITICAL - Your response must be valid JSON. Escape ALL special characters in string values:
40
13
  - Newlines → \\n
@@ -44,45 +17,41 @@ CRITICAL - Your response must be valid JSON. Escape ALL special characters in st
44
17
  - Backslashes → \\\\
45
18
  Do NOT include raw newlines, tabs, or unescaped quotes inside JSON string values.
46
19
  `.trim();
47
- const REPORT_PROGRESS_TOOL = `
48
- Report Progress to User
49
- Before each task or step, call the tool report_progress with:
50
- message: brief description of work you plan to do that makes sense for individuals in Learning and Development or Business Strategy roles. Don't include the word JSON in any of the messages.
51
- percent: cumulative completion percentage with values between {minPercent}% and {maxPercent}%. Don't report percentage completion outside of this range.
52
- `;
53
20
  /**
54
21
  * Converts a Zod validation error object into a human-readable multi-line string.
55
22
  * Each failing field is described with its dot-notation path and a contextual message
56
23
  * that depends on the Zod error code (e.g. `invalid_type`, `too_small`, `invalid_enum_value`).
57
24
  * The output is prefixed with `"Schema validation failed:"` followed by a bulleted list.
58
- * @param zodError - The Zod `ZodError` instance whose `errors` array will be formatted
25
+ * @param zodError - The Zod `ZodError` instance whose `issues` array will be formatted
59
26
  * @returns A formatted string describing all validation failures
60
27
  */
61
28
  const formatZodErrors = (zodError) => {
62
- const errors = zodError.errors.map((err) => {
63
- const path = err.path.length > 0 ? err.path.join(".") : "root";
64
- // Provide more context based on error type
65
- switch (err.code) {
66
- case "invalid_type":
67
- return `Field "${path}": Expected ${err.expected}, but received ${err.received}`;
68
- case "invalid_literal":
69
- return `Field "${path}": Expected literal value ${JSON.stringify(err.expected)}, but received ${JSON.stringify(err.received)}`;
70
- case "unrecognized_keys":
71
- return `Field "${path}": Unrecognized keys: ${err.keys.join(", ")}`;
72
- case "invalid_union":
73
- return `Field "${path}": Invalid union - none of the expected types matched`;
74
- case "invalid_enum_value":
75
- return `Field "${path}": Invalid enum value. Expected one of: ${err.options.join(", ")}`;
76
- case "invalid_string":
77
- return `Field "${path}": Invalid string format (${err.validation})`;
78
- case "too_small":
79
- return `Field "${path}": Value is too small (minimum: ${err.minimum})`;
80
- case "too_big":
81
- return `Field "${path}": Value is too large (maximum: ${err.maximum})`;
82
- default:
83
- return `Field "${path}": ${err.message}`;
84
- }
85
- });
29
+ const errors = zodError.issues
30
+ ? zodError.issues.map((err) => {
31
+ const path = err.path.length > 0 ? err.path.join(".") : "root";
32
+ // Provide more context based on error type
33
+ switch (err.code) {
34
+ case "invalid_type":
35
+ return `Field "${path}": Expected ${err.expected}, but received ${err.received}`;
36
+ case "invalid_literal":
37
+ return `Field "${path}": Expected literal value ${JSON.stringify(err.expected)}, but received ${JSON.stringify(err.received)}`;
38
+ case "unrecognized_keys":
39
+ return `Field "${path}": Unrecognized keys: ${err.keys.join(", ")}`;
40
+ case "invalid_union":
41
+ return `Field "${path}": Invalid union - none of the expected types matched`;
42
+ case "invalid_enum_value":
43
+ return `Field "${path}": Invalid enum value. Expected one of: ${err.options.join(", ")}`;
44
+ case "invalid_string":
45
+ return `Field "${path}": Invalid string format (${err.validation})`;
46
+ case "too_small":
47
+ return `Field "${path}": Value is too small (minimum: ${err.minimum})`;
48
+ case "too_big":
49
+ return `Field "${path}": Value is too large (maximum: ${err.maximum})`;
50
+ default:
51
+ return `Field "${path}": ${err.message}`;
52
+ }
53
+ })
54
+ : [];
86
55
  return `Schema validation failed:\n - ${errors.join("\n - ")}`;
87
56
  };
88
57
  /**
@@ -217,82 +186,6 @@ const extractJSONFromResponse = (text) => {
217
186
  }
218
187
  return null;
219
188
  };
220
- /**
221
- * Attempts to parse a JSON string without throwing. On failure it tries a second parse
222
- * after sanitising Unicode control characters and normalising Windows-style line endings
223
- * to `\n`. Returns `null` if both attempts fail, making it safe to call in stream
224
- * processing loops where individual NDJSON lines may be malformed.
225
- * @param line - A single line of text expected to contain a JSON value
226
- * @returns The parsed value, or `null` if parsing failed after both attempts
227
- */
228
- const safeJsonParse = (line) => {
229
- try {
230
- return JSON.parse(line);
231
- }
232
- catch {
233
- // Try with special character handling
234
- try {
235
- const sanitized = line
236
- .replace(/[\u0000-\u001F\u007F-\u009F]/g, " ")
237
- .replace(/\r\n/g, "\\n")
238
- .replace(/\r/g, "\\n");
239
- return JSON.parse(sanitized);
240
- }
241
- catch {
242
- return null;
243
- }
244
- }
245
- };
246
- /**
247
- * Creates a LangChain tool named `report_progress` that agents can call to emit
248
- * progress updates during multi-step execution. When the tool is invoked by the agent,
249
- * it clamps the reported percentage to the `[minPercent, maxPercent]` range, forwards
250
- * the update to the `onProgress` callback, and returns a JSON acknowledgement string to
251
- * the agent. If the callback throws, the tool returns a JSON error acknowledgement
252
- * instead of propagating the exception.
253
- * @param onProgress - Async or sync callback invoked with `{ progress, message }` on
254
- * each agent progress report
255
- * @param minPercent - The lower bound of the percentage range the agent is allowed to
256
- * report; defaults to `0`
257
- * @param maxPercent - The upper bound of the percentage range the agent is allowed to
258
- * report; defaults to `100`
259
- * @returns A LangChain tool instance configured with a Zod schema for `{ stage, message, percent }`
260
- */
261
- const getAIProgressTool = (onProgress, minPercent = 0, maxPercent = 100) => langChain.tool(async ({ stage, message, percent }) => {
262
- let clampedPct = 0;
263
- try {
264
- // Coerce percent to number and clamp to be within minPercent and maxPercent bounds
265
- const rawPct = typeof percent === "number" ? percent : parseFloat(percent) || 0;
266
- clampedPct = Math.max(minPercent, Math.min(maxPercent, rawPct));
267
- // NOTE: Monotonicity is not enforced here; implement if needed per session/context.
268
- if (onProgress &&
269
- minPercent <= clampedPct &&
270
- clampedPct <= maxPercent) {
271
- await onProgress({
272
- progress: clampedPct,
273
- message: message,
274
- });
275
- }
276
- }
277
- finally {
278
- return JSON.stringify({
279
- acknowledged: true,
280
- receivedAt: Date.now(),
281
- stage,
282
- percent: clampedPct,
283
- });
284
- }
285
- }, {
286
- name: "report_progress",
287
- description: `Mandatory after each phase: call with {stage, message, percent}. Percent must be between ${minPercent} and ${maxPercent} and monotonically increase.`,
288
- schema: z.object({
289
- stage: z.string().describe("Current stage of the process"),
290
- message: z.string().describe("Progress message to display"),
291
- percent: z
292
- .number()
293
- .describe(`Progress percentage (${minPercent}-${maxPercent})`),
294
- }),
295
- });
296
189
  /**
297
190
  * Instantiates and returns the appropriate LangChain chat model based on the model
298
191
  * name prefix. `claude-` maps to `ChatAnthropic`, `gemini-` maps to
@@ -358,11 +251,7 @@ const getLLMModel = (modelName, config, schema = null) => {
358
251
  };
359
252
  /**
360
253
  * Constructs a LangChain agent configured with a specified model, system prompt, and
361
- * set of tools. When an `onReportProgress` callback is provided, the NDJSON progress-
362
- * reporting instruction block is appended to the system prompt and a `report_progress`
363
- * tool is added to the tools array so the agent can emit incremental progress updates
364
- * during execution. The percentage range for progress reporting is bounded by
365
- * `minPercent` and `maxPercent`.
254
+ * set of tools.
366
255
  * @param name - A human-readable display name for the agent
367
256
  * @param modelName - The LLM identifier passed to `getLLMModel` (e.g. `"gpt-4o"`)
368
257
  * @param systemPrompt - The base system prompt describing the agent's role and behaviour
@@ -371,30 +260,14 @@ const getLLMModel = (modelName, config, schema = null) => {
371
260
  * @param responseFormat - Optional structured response format descriptor passed to the
372
261
  * LangChain agent constructor
373
262
  * @param config - Configuration object forwarded to `getLLMModel` (API keys, temperature, etc.)
374
- * @param onReportProgress - Optional callback for progress updates; when provided the
375
- * progress tool is added and the system prompt is extended
376
- * @param minPercent - Minimum progress percentage the agent is allowed to report;
377
- * defaults to `0`
378
- * @param maxPercent - Maximum progress percentage the agent is allowed to report;
379
- * defaults to `100`
380
- * @returns A configured LangChain agent instance ready to be run with `runAIAgent`
263
+ * @returns A configured LangChain agent instance ready to be run with `runAgent`
381
264
  */
382
- const createAgent = (name, modelName, systemPrompt, tools = [], responseFormat, config, onReportProgress = null, minPercent = 0, maxPercent = 100) => {
383
- //Prepare the complete system prompt with progress reporting instructions if needed
384
- const completeSystemPrompt = `
385
- ${systemPrompt}
386
- ${onReportProgress
387
- ? REPORT_PROGRESS_TOOL.replace(`{minPercent}`, minPercent).replace(`{maxPercent}`, maxPercent)
388
- : ""}
389
- `.trim();
390
- // Create the agent with the specified model, system prompt, tools, and response format
265
+ const createAgent = (name, modelName, systemPrompt, tools = [], responseFormat, config) => {
391
266
  const agent = langChain.createAgent({
392
267
  name: name,
393
268
  model: getLLMModel(modelName, config),
394
- systemPrompt: completeSystemPrompt,
395
- tools: onReportProgress
396
- ? [...tools, getAIProgressTool(onReportProgress, minPercent, maxPercent)]
397
- : tools,
269
+ systemPrompt: systemPrompt.trim(),
270
+ tools,
398
271
  ...(responseFormat ? { responseFormat: responseFormat } : {}),
399
272
  });
400
273
  return agent;
@@ -402,10 +275,9 @@ const createAgent = (name, modelName, systemPrompt, tools = [], responseFormat,
402
275
  /**
403
276
  * Executes a LangChain agent with a single user prompt and returns the raw agent
404
277
  * response. When an `onProgress` callback is provided, LangChain callbacks are
405
- * registered to forward `tool_start`, `tool_end` (including structured progress data
406
- * from the `report_progress` tool), `agent_action`, and error events to the caller.
278
+ * registered to forward `tool_start`, `agent_action`, and error events to the caller.
407
279
  * Execution time is logged at the info level on completion.
408
- * @param agent - A LangChain agent instance created by `createAIAgent`
280
+ * @param agent - A LangChain agent instance created by `createAgent`
409
281
  * @param prompt - The user message string to send to the agent
410
282
  * @param config - Configuration object; `config.recursionLimit` controls the maximum
411
283
  * number of agent steps (defaults to `25` when not specified)
@@ -415,8 +287,6 @@ const createAgent = (name, modelName, systemPrompt, tools = [], responseFormat,
415
287
  */
416
288
  const runAgent = async (agent, prompt, config, onProgress = null) => {
417
289
  const startTime = Date.now();
418
- // Build callbacks for progress reporting if onProgress is provided
419
- // const agentDisplayName = `${agent.options?.name} AI Agent`;
420
290
  const callbacks = onProgress
421
291
  ? [
422
292
  {
@@ -428,24 +298,6 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
428
298
  tool: toolInfo.name,
429
299
  });
430
300
  },
431
- handleToolEnd: (output) => {
432
- try {
433
- const progressInfo = extractJSONFromResponse(output.content);
434
- if (progressInfo &&
435
- typeof progressInfo === "object" &&
436
- progressInfo.percent &&
437
- progressInfo.message) {
438
- onProgress({
439
- progress: progressInfo.percent,
440
- message: progressInfo.message,
441
- output: typeof output === "string"
442
- ? output
443
- : JSON.stringify(output),
444
- });
445
- }
446
- }
447
- catch { }
448
- },
449
301
  handleAgentAction: (action) => {
450
302
  onProgress({
451
303
  type: "agent_action",
@@ -454,18 +306,6 @@ const runAgent = async (agent, prompt, config, onProgress = null) => {
454
306
  input: action.toolInput,
455
307
  });
456
308
  },
457
- // handleLLMStart: () => {
458
- // onProgress({
459
- // type: "llm_start",
460
- // message: `${agentDisplayName} is thinking`,
461
- // });
462
- // },
463
- // handleLLMEnd: () => {
464
- // onProgress({
465
- // type: "llm_end",
466
- // message: `${agentDisplayName} has completed processing`,
467
- // });
468
- // },
469
309
  handleChainError: (err) => {
470
310
  onProgress({
471
311
  type: "error",
@@ -569,26 +409,11 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
569
409
  ? `${JSON_ESCAPE_INSTRUCTION}\n\n---\n\n${systemPrompt}`
570
410
  : JSON_ESCAPE_INSTRUCTION;
571
411
  }
572
- // Build tools array and progress callback for agentic mode
573
- const progressTracker = { current: minPercent };
574
- const agentTools = [...tools];
575
- let trackingProgressCallback = null;
576
- if (onProgressReport) {
577
- trackingProgressCallback = (data) => {
578
- if (typeof data.progress === "number") {
579
- progressTracker.current = data.progress;
580
- }
581
- onProgressReport(data);
582
- };
583
- }
584
- // Create the agent with tools and progress callback
585
- const agent = createAgent(agentName, modelName, systemPrompt, agentTools, null, // responseFormat
586
- config, trackingProgressCallback, minPercent, maxPercent);
587
- // Run the agent with callback that includes current progress
588
- const callbackWithProgress = onProgressReport
589
- ? (data) => onProgressReport({ ...data, progress: progressTracker.current })
590
- : null;
591
- const response = await runAgent(agent, userPrompt, config, callbackWithProgress);
412
+ // Create the agent with tools
413
+ const agent = createAgent(agentName, modelName, systemPrompt, [...tools], null, // responseFormat
414
+ config);
415
+ // Run the agent with progress callback
416
+ const response = await runAgent(agent, userPrompt, config, onProgressReport || null);
592
417
  // Extract content from agent response
593
418
  const messages = response?.messages || [];
594
419
  if (messages.length === 0) {
@@ -661,224 +486,112 @@ const runPromptWithModel = async (modelName, config, prompt, onProgressReport, m
661
486
  throw new Error("Prompt must be a string or array of messages");
662
487
  }
663
488
  if (onProgressReport) {
664
- // Check if we can use native response_format (OpenAI with schema)
665
- const useNativeStreamingSchema = expectsJsonResponse && schema && modelName.startsWith("gpt-");
666
- if (useNativeStreamingSchema) {
667
- // OpenAI with schema: use native response_format and stream with chunk-based progress
668
- const llm = getLLMModel(modelName, config, schema);
669
- const messagesToSend = messages.length > 0 && messages[0].role === "system"
670
- ? messages
671
- : [
672
- { role: "system", content: "Respond with valid JSON." },
673
- ...messages,
489
+ // Streaming mode: use server-side chunk-based progress for all models
490
+ const useNativeSchema = expectsJsonResponse && schema && modelName.startsWith("gpt-");
491
+ const llm = getLLMModel(modelName, config, useNativeSchema ? schema : null);
492
+ // Build messages with JSON instructions if needed
493
+ let messagesToSend;
494
+ if (expectsJsonResponse) {
495
+ let systemContent = useNativeSchema
496
+ ? "Respond with valid JSON."
497
+ : JSON_ESCAPE_INSTRUCTION;
498
+ if (schema && !useNativeSchema) {
499
+ const jsonSchema = zodToJsonSchema(schema, { target: "openApi3" });
500
+ systemContent += `\n\nYour response MUST conform to this JSON schema:\n${JSON.stringify(jsonSchema, null, 2)}`;
501
+ }
502
+ if (messages.length > 0 && messages[0].role === "system") {
503
+ messagesToSend = [
504
+ {
505
+ role: "system",
506
+ content: `${systemContent}\n\n---\n\n${messages[0].content}`,
507
+ },
508
+ ...messages.slice(1),
674
509
  ];
675
- let rawContent = "";
676
- let lastProgressReport = 0;
677
- const progressInterval = 1000; // Report progress every 1000 characters
678
- const stream = await llm.stream(messagesToSend);
679
- for await (const chunk of stream) {
680
- const content = chunk?.content || chunk;
681
- if (typeof content === "string") {
682
- rawContent += content;
683
- // Report progress based on content length
684
- if (rawContent.length - lastProgressReport >= progressInterval) {
685
- lastProgressReport = rawContent.length;
686
- const progress = Math.min(maxPercent - 5, // Reserve last 5% for completion
687
- minPercent +
688
- Math.floor((rawContent.length / 5000) * (maxPercent - minPercent)));
689
- await onProgressReport({
690
- message: "Generating content...",
691
- progress: progress,
692
- });
693
- }
694
- }
695
510
  }
696
- // Report completion
697
- await onProgressReport({
698
- message: "Processing complete",
699
- progress: maxPercent,
700
- });
701
- // Parse and validate the response
702
- if (rawContent) {
703
- try {
704
- const parsed = JSON.parse(rawContent.trim());
705
- if (schema) {
706
- validateWithSchema(parsed, schema);
707
- }
708
- return JSON.stringify(parsed);
709
- }
710
- catch (parseError) {
711
- if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
712
- throw parseError;
713
- }
714
- // Try to extract JSON
715
- const extracted = extractJSONFromResponse(rawContent);
716
- if (extracted) {
717
- if (schema) {
718
- validateWithSchema(extracted, schema);
719
- }
720
- return JSON.stringify(extracted);
721
- }
722
- if (expectsJsonResponse) {
723
- let preview = "";
724
- if (typeof rawContent === "string") {
725
- preview = rawContent.substring(0, 100);
726
- }
727
- else if (typeof rawContent === "object" && rawContent !== null) {
728
- preview = JSON.stringify(rawContent).substring(0, 100);
729
- }
730
- else if (rawContent !== undefined && rawContent !== null) {
731
- preview = String(rawContent).substring(0, 100);
732
- }
733
- else {
734
- preview = "[empty response]";
735
- }
736
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
737
- }
738
- return rawContent;
739
- }
511
+ else {
512
+ messagesToSend = [
513
+ { role: "system", content: systemContent },
514
+ ...messages,
515
+ ];
740
516
  }
741
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
742
- }
743
- // Non-OpenAI or no schema: use NDJSON streaming approach
744
- const llm = getLLMModel(modelName, config);
745
- let ndjsonSystemContent = NDJSON_SYSTEM_PROMPT.replace(`{minPercent}`, minPercent).replace(`{maxPercent}`, maxPercent);
746
- // If schema is provided, include it in the system prompt
747
- if (expectsJsonResponse && schema) {
748
- const jsonSchema = zodToJsonSchema(schema, { target: "openApi3" });
749
- ndjsonSystemContent += `\n\nThe "content" field in the "final" event MUST conform to this JSON schema:\n${JSON.stringify(jsonSchema, null, 2)}`;
750
- }
751
- let messagesWithNDJSON;
752
- if (messages.length > 0 && messages[0].role === "system") {
753
- messagesWithNDJSON = [
754
- {
755
- role: "system",
756
- content: `${ndjsonSystemContent}\n\n---\n\n${messages[0].content}`,
757
- },
758
- ...messages.slice(1),
759
- ];
760
517
  }
761
518
  else {
762
- messagesWithNDJSON = [
763
- {
764
- role: "system",
765
- content: ndjsonSystemContent,
766
- },
767
- ...messages,
768
- ];
519
+ messagesToSend = messages;
769
520
  }
770
- // Stream the response and parse NDJSON events
771
- let buffer = "";
772
- let finalContent = "";
521
+ // Stream and report server-side progress based on time elapsed
773
522
  let rawContent = "";
774
- const stream = await llm.stream(messagesWithNDJSON);
523
+ let chunkCount = 0;
524
+ const progressReportInterval = 10; // Report every N chunks
525
+ const startTime = Date.now();
526
+ // Use a time-based asymptotic curve: progress approaches maxPercent but never
527
+ // overshoots. This avoids the magic "expected length" constant — longer responses
528
+ // simply slow the curve down rather than exceeding the range.
529
+ const expectedDurationMs = 15_000; // Tune: expected typical response time
530
+ const stream = await llm.stream(messagesToSend);
775
531
  for await (const chunk of stream) {
776
532
  const content = chunk?.content || chunk;
777
533
  if (typeof content === "string") {
778
- buffer += content;
779
534
  rawContent += content;
780
- const lines = buffer.split("\n");
781
- buffer = lines.pop() || "";
782
- for (const line of lines) {
783
- const trimmedLine = line.trim();
784
- if (!trimmedLine)
785
- continue;
786
- const parsed = safeJsonParse(trimmedLine);
787
- if (parsed) {
788
- if (parsed.type === "progress") {
789
- const clampedProgress = Math.max(minPercent, Math.min(maxPercent, parsed.pct));
790
- await onProgressReport({
791
- message: parsed.message,
792
- progress: clampedProgress,
793
- });
794
- }
795
- else if (parsed.type === "final") {
796
- if (parsed.content) {
797
- finalContent = parsed.content;
798
- }
799
- }
800
- }
535
+ chunkCount++;
536
+ if (chunkCount % progressReportInterval === 0) {
537
+ const elapsed = Date.now() - startTime;
538
+ // Asymptotic curve: fast early progress that slows as it approaches max
539
+ const progress = Math.round(minPercent +
540
+ (maxPercent - minPercent - 5) *
541
+ (1 - Math.exp(-elapsed / expectedDurationMs)));
542
+ await onProgressReport({
543
+ message: "Generating content...",
544
+ progress: Math.min(progress, maxPercent - 5),
545
+ });
801
546
  }
802
547
  }
803
548
  }
804
- if (buffer.trim()) {
805
- const parsed = safeJsonParse(buffer.trim());
806
- if (parsed && parsed.type === "final" && parsed.content) {
807
- finalContent = parsed.content;
808
- }
549
+ await onProgressReport({
550
+ message: "Processing complete",
551
+ progress: maxPercent,
552
+ });
553
+ if (!rawContent) {
554
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
809
555
  }
810
- if (finalContent) {
811
- if (typeof finalContent === "object") {
812
- if (expectsJsonResponse && schema) {
813
- validateWithSchema(finalContent, schema);
814
- }
815
- return JSON.stringify(finalContent);
816
- }
817
- if (typeof finalContent === "string") {
818
- try {
819
- const parsed = JSON.parse(finalContent);
820
- if (expectsJsonResponse && schema) {
821
- validateWithSchema(parsed, schema);
822
- }
823
- return JSON.stringify(parsed);
824
- }
825
- catch (parseError) {
826
- if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
827
- throw parseError;
828
- }
829
- return finalContent;
830
- }
831
- }
832
- return finalContent;
556
+ // If not expecting JSON, return raw content directly
557
+ if (!expectsJsonResponse) {
558
+ return rawContent;
833
559
  }
834
- if (rawContent) {
835
- const lines = rawContent.split("\n").filter((l) => l.trim());
836
- let extractedContent = "";
837
- for (const line of lines) {
838
- const parsed = safeJsonParse(line.trim());
839
- if (parsed && parsed.type === "final" && parsed.content) {
840
- extractedContent = parsed.content;
841
- break;
842
- }
843
- }
844
- if (extractedContent) {
845
- try {
846
- const parsed = JSON.parse(extractedContent);
847
- if (expectsJsonResponse && schema) {
848
- validateWithSchema(parsed, schema);
849
- }
850
- return JSON.stringify(parsed);
851
- }
852
- catch {
853
- return extractedContent;
560
+ // Parse and validate JSON response
561
+ const trimmed = rawContent.trim();
562
+ if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
563
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
564
+ try {
565
+ const parsed = JSON.parse(trimmed);
566
+ if (schema) {
567
+ validateWithSchema(parsed, schema);
854
568
  }
569
+ return JSON.stringify(parsed);
855
570
  }
856
- const extracted = extractJSONFromResponse(rawContent);
857
- if (extracted) {
858
- if (expectsJsonResponse && schema) {
859
- validateWithSchema(extracted, schema);
571
+ catch (parseError) {
572
+ if (parseError?.code === constants.HTTP_CODES.UNPROCESSABLE_ENTITY) {
573
+ throw parseError;
860
574
  }
861
- return JSON.stringify(extracted);
862
575
  }
863
- if (expectsJsonResponse) {
864
- let preview = "";
865
- if (typeof rawContent === "string") {
866
- preview = rawContent.substring(0, 100);
867
- }
868
- else if (typeof rawContent === "object" && rawContent !== null) {
869
- preview = JSON.stringify(rawContent).substring(0, 100);
870
- }
871
- else if (rawContent !== undefined && rawContent !== null) {
872
- preview = String(rawContent).substring(0, 100);
873
- }
874
- else {
875
- preview = "[empty response]";
876
- }
877
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
576
+ }
577
+ const extracted = extractJSONFromResponse(rawContent);
578
+ if (extracted) {
579
+ if (schema) {
580
+ validateWithSchema(extracted, schema);
878
581
  }
879
- return rawContent;
582
+ return JSON.stringify(extracted);
583
+ }
584
+ let preview = "";
585
+ if (typeof rawContent === "string") {
586
+ preview = rawContent.substring(0, 100);
587
+ }
588
+ else if (typeof rawContent === "object" && rawContent !== null) {
589
+ preview = JSON.stringify(rawContent).substring(0, 100);
590
+ }
591
+ else {
592
+ preview = "[empty response]";
880
593
  }
881
- throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, "LLM returned no content");
594
+ throw errorHandlingHelper.create(constants.HTTP_CODES.INTERNAL_SERVER_ERROR, `LLM returned non-JSON response when JSON was expected: ${preview}...`);
882
595
  }
883
596
  else {
884
597
  // Non-streaming mode: use native response_format for OpenAI when schema is provided