@exulu/backend 2.0.0 → 2.1.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/dist/index.js CHANGED
@@ -7,6 +7,9 @@ import {
7
7
  validatePythonEnvironment
8
8
  } from "./chunk-T6JVFT7L.js";
9
9
  import {
10
+ COMPACTION_INSUFFICIENT,
11
+ ContextCompactionRequiredError,
12
+ DEFAULT_CONTEXT_WINDOW,
10
13
  ExuluTool,
11
14
  LITELLM_UI_PATH,
12
15
  LiteLLMAdminError,
@@ -18,16 +21,19 @@ import {
18
21
  buildTags,
19
22
  checkLicense,
20
23
  checkRecordAccess,
24
+ contextOccupancy,
21
25
  convertExuluToolsToAiSdkTools,
22
26
  copyS3Object,
23
27
  createAgenticRetrievalTool,
24
- createProjectItemsRetrievalTool,
25
28
  createTaggedFetch,
26
29
  createUppyRoutes,
27
30
  decryptOauthState,
28
31
  deleteS3Object,
32
+ deriveContextBudget,
29
33
  downloadKeyIntoSandbox,
30
34
  enableLiteLLMClientMode,
35
+ estimateMessageTokens,
36
+ estimateTokens,
31
37
  exchangeCodeForTokens,
32
38
  exuluApp,
33
39
  getBudgetSettings,
@@ -40,10 +46,12 @@ import {
40
46
  getTagDailyActivity,
41
47
  getToken,
42
48
  getUserBudgetView,
49
+ guardExtractedFileText,
43
50
  invalidateBudgetCaches,
44
51
  isLiteLLMEnabled,
45
52
  listS3ObjectsByPrefix,
46
53
  listTagsByPrefix,
54
+ mapStreamErrorMessage,
47
55
  oauthRegistry,
48
56
  oauthTokenStore,
49
57
  postgresClient,
@@ -55,15 +63,17 @@ import {
55
63
  sanitizeToolName,
56
64
  setBudgetSettings,
57
65
  setLiteLLMPackageRoot,
66
+ sliceHistoryAtCheckpoint,
58
67
  startLiteLLMSupervisor,
59
68
  tagDelete,
60
69
  tagInfo,
70
+ truncateToolOutput,
61
71
  updateStatistic,
62
72
  uploadFile,
63
73
  upsertBudget,
64
74
  waitForLiteLLMReady,
65
75
  withRetry
66
- } from "./chunk-IJ4HNHOT.js";
76
+ } from "./chunk-RVZWZNWG.js";
67
77
  import {
68
78
  findLiteLLMModel
69
79
  } from "./chunk-7CCMW3IW.js";
@@ -2213,6 +2223,13 @@ var agentsSchema = {
2213
2223
  name: "sandbox_enabled",
2214
2224
  type: "boolean",
2215
2225
  default: false
2226
+ },
2227
+ {
2228
+ // Per-turn budget for ALL tool steps on one chat message (bash, files,
2229
+ // knowledge search, integrations). 0/null = platform default
2230
+ // (DEFAULT_MAX_STEPS in resolve-max-steps.ts). Auto-ALTERed on boot.
2231
+ name: "max_tool_steps",
2232
+ type: "number"
2216
2233
  }
2217
2234
  ]
2218
2235
  };
@@ -4252,7 +4269,7 @@ var ExuluContext2 = class {
4252
4269
  embedder,
4253
4270
  chunker,
4254
4271
  processor,
4255
- active,
4272
+ active: active2,
4256
4273
  fields,
4257
4274
  queryRewriter,
4258
4275
  resultReranker,
@@ -4283,7 +4300,7 @@ var ExuluContext2 = class {
4283
4300
  this.description = description;
4284
4301
  this.embedder = embedder;
4285
4302
  this.chunker = chunker;
4286
- this.active = active;
4303
+ this.active = active2;
4287
4304
  this.queryRewriter = queryRewriter;
4288
4305
  this.resultReranker = resultReranker;
4289
4306
  this.entities = entities;
@@ -5398,14 +5415,26 @@ var addProviderFields = async (args, requestedFields, providers, result, tools,
5398
5415
  )
5399
5416
  );
5400
5417
  if (args.project) {
5401
- const projectTool = await createProjectItemsRetrievalTool({
5402
- projectId: args.project,
5403
- user,
5404
- role: user.role?.id,
5405
- contexts
5406
- });
5407
- if (projectTool) {
5408
- result.tools.unshift(projectTool);
5418
+ const hasAgentic = result.tools.some(
5419
+ (tool2) => tool2?.id === "agentic_context_search"
5420
+ );
5421
+ if (!hasAgentic) {
5422
+ const instance = createAgenticRetrievalTool({
5423
+ contexts: [],
5424
+ user,
5425
+ role: user.role?.id,
5426
+ model: void 0
5427
+ });
5428
+ if (instance) {
5429
+ result.tools.unshift({
5430
+ id: instance.id,
5431
+ name: instance.name,
5432
+ description: instance.description,
5433
+ category: instance.category,
5434
+ type: instance.type,
5435
+ config: []
5436
+ });
5437
+ }
5409
5438
  }
5410
5439
  }
5411
5440
  result.tools = result.tools.filter((tool2) => tool2 !== null);
@@ -8852,7 +8881,7 @@ var durationFromSegments = (segments) => {
8852
8881
 
8853
8882
  // src/exulu/recall/service.ts
8854
8883
  var TABLE2 = "transcription_jobs";
8855
- var DEFAULT_BOT_NAME = "Exulu Notetaker";
8884
+ var DEFAULT_BOT_NAME = "Company Notetaker";
8856
8885
  var log3 = (msg) => console.log(`[EXULU-RECALL] ${msg}`);
8857
8886
  var parseJson = (v) => {
8858
8887
  if (v == null) return null;
@@ -11275,14 +11304,15 @@ import bodyParser from "body-parser";
11275
11304
  import CryptoJS4 from "crypto-js";
11276
11305
  import OpenAI from "openai";
11277
11306
  import fs2 from "fs";
11278
- import { randomUUID as randomUUID2 } from "crypto";
11307
+ import { randomUUID as randomUUID3 } from "crypto";
11279
11308
  import "@opentelemetry/api";
11280
11309
  import JSZip2 from "jszip";
11281
11310
  import { createIdGenerator } from "ai";
11282
11311
  import cookieParser from "cookie-parser";
11283
11312
 
11284
11313
  // src/exulu/resolve-max-steps.ts
11285
- function resolveMaxStepsFromToolConfigs(toolConfigs) {
11314
+ var DEFAULT_MAX_STEPS = 10;
11315
+ function resolveRetrievalCallBudget(toolConfigs) {
11286
11316
  const agentic = toolConfigs?.find((t) => t.id === "agentic_context_search");
11287
11317
  if (!agentic?.config) return void 0;
11288
11318
  const entry = agentic.config.find((c) => c.name === "max_steps");
@@ -11291,10 +11321,146 @@ function resolveMaxStepsFromToolConfigs(toolConfigs) {
11291
11321
  const n = typeof raw === "number" ? raw : parseInt(String(raw ?? ""), 10);
11292
11322
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : void 0;
11293
11323
  }
11324
+ function resolveTurnStepBudget(maxStepCount, agent) {
11325
+ if (typeof maxStepCount === "number" && Number.isFinite(maxStepCount) && maxStepCount > 0) {
11326
+ return Math.floor(maxStepCount);
11327
+ }
11328
+ const raw = agent?.max_tool_steps;
11329
+ const n = typeof raw === "number" ? raw : parseInt(String(raw ?? ""), 10);
11330
+ if (Number.isFinite(n) && n > 0) {
11331
+ return Math.floor(n);
11332
+ }
11333
+ return DEFAULT_MAX_STEPS;
11334
+ }
11335
+ function flattenPart(part) {
11336
+ const p = part;
11337
+ if (p?.type === "text") return p.text ?? "";
11338
+ if (p?.type === "tool-call") {
11339
+ return `Earlier, the assistant ran the "${p.toolName}" tool with input: ${JSON.stringify(p.input ?? {}).slice(0, 300)}`;
11340
+ }
11341
+ if (p?.type === "tool-result") {
11342
+ const out = p.output?.value ?? p.output;
11343
+ return `The "${p.toolName}" tool returned: ${typeof out === "string" ? out : JSON.stringify(out ?? "")}`;
11344
+ }
11345
+ return "";
11346
+ }
11347
+ function flattenToolHistory(messages) {
11348
+ return messages.map((m) => {
11349
+ const msg = m;
11350
+ if (msg.role === "tool") {
11351
+ const text = (Array.isArray(msg.content) ? msg.content : []).map(flattenPart).filter(Boolean).join("\n");
11352
+ return { role: "user", content: text || "(tool results)" };
11353
+ }
11354
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
11355
+ const text = msg.content.map(flattenPart).filter(Boolean).join("\n");
11356
+ return { role: "assistant", content: text || "(searching)" };
11357
+ }
11358
+ return m;
11359
+ });
11360
+ }
11361
+ var FINAL_ANSWER_INSTRUCTION = `This is your last step for this turn. Answer the user's original question now, in plain text, using only the information gathered above. If you could not finish the task, tell the user you reached the maximum number of tool steps, summarize what you found and did so far, and say what remains \u2014 they can ask you to continue. Do not attempt any further tool calls. Write your answer as normal prose for the user: do not output tool-call syntax, JSON commands, or bracketed lines such as "[called tool ...]" \u2014 describe anything you did or still plan to do in plain language.`;
11294
11362
  function finalAnswerGuard(maxSteps) {
11295
- return ({ stepNumber }) => stepNumber >= maxSteps - 1 ? { toolChoice: "none" } : void 0;
11363
+ return ({ stepNumber, messages }) => stepNumber >= maxSteps - 1 ? {
11364
+ toolChoice: "none",
11365
+ activeTools: [],
11366
+ ...Array.isArray(messages) ? {
11367
+ messages: [
11368
+ ...flattenToolHistory(messages),
11369
+ { role: "user", content: FINAL_ANSWER_INSTRUCTION }
11370
+ ]
11371
+ } : {}
11372
+ } : void 0;
11373
+ }
11374
+ function retrievalBudgetGuard(limit, agenticToolKey, allToolKeys) {
11375
+ if (limit == null || limit <= 0 || !agenticToolKey || !allToolKeys.includes(agenticToolKey)) {
11376
+ return () => void 0;
11377
+ }
11378
+ const remainingTools = allToolKeys.filter((k) => k !== agenticToolKey);
11379
+ return ({ steps }) => {
11380
+ const calls = (steps ?? []).flatMap((s) => s?.toolCalls ?? []).filter((c) => c?.toolName === agenticToolKey).length;
11381
+ if (calls < limit) return void 0;
11382
+ return { activeTools: remainingTools };
11383
+ };
11384
+ }
11385
+
11386
+ // src/exulu/context-guard.ts
11387
+ var KEEP_RECENT_TOOL_MESSAGES = 2;
11388
+ var COLLAPSE_KEEP_CHARS = 400;
11389
+ var COLLAPSE_MARKER = " \u2026[older tool output collapsed mid-response to fit the context window \u2014 the full output is in the session file named in the notice above, if one was saved]";
11390
+ function contextGuard(contextWindow) {
11391
+ const budget = deriveContextBudget(contextWindow);
11392
+ return async ({ messages }) => {
11393
+ if (!Array.isArray(messages) || messages.length === 0) return void 0;
11394
+ const tokens = estimateTokens(JSON.stringify(messages));
11395
+ if (tokens < budget.usableWindow) return void 0;
11396
+ const toolIndices = messages.map((m, i) => m?.role === "tool" ? i : -1).filter((i) => i !== -1);
11397
+ const collapsible = new Set(toolIndices.slice(0, Math.max(0, toolIndices.length - KEEP_RECENT_TOOL_MESSAGES)));
11398
+ if (collapsible.size === 0) return void 0;
11399
+ let changed = false;
11400
+ const next = messages.map((m, i) => {
11401
+ if (!collapsible.has(i)) return m;
11402
+ const msg = m;
11403
+ if (!Array.isArray(msg.content)) return m;
11404
+ const content = msg.content.map((part) => {
11405
+ const p = part;
11406
+ if (p?.type !== "tool-result") return part;
11407
+ const out = p.output?.value ?? p.output;
11408
+ const asText = typeof out === "string" ? out : JSON.stringify(out ?? "");
11409
+ if (asText.length <= COLLAPSE_KEEP_CHARS + COLLAPSE_MARKER.length) return part;
11410
+ changed = true;
11411
+ return { ...part, output: { type: "text", value: asText.slice(0, COLLAPSE_KEEP_CHARS) + COLLAPSE_MARKER } };
11412
+ });
11413
+ return { ...m, content };
11414
+ });
11415
+ return changed ? { messages: next } : void 0;
11416
+ };
11417
+ }
11418
+ function composePrepareSteps(...guards) {
11419
+ return async (opts) => {
11420
+ let merged;
11421
+ let messages = opts.messages;
11422
+ for (const guard of guards) {
11423
+ const result = await guard({ ...opts, messages });
11424
+ if (!result) continue;
11425
+ merged = { ...merged ?? {}, ...result };
11426
+ if (Array.isArray(result.messages)) {
11427
+ messages = result.messages;
11428
+ }
11429
+ }
11430
+ if (merged && messages && !("messages" in merged)) {
11431
+ merged.messages = messages;
11432
+ }
11433
+ return merged;
11434
+ };
11296
11435
  }
11297
11436
 
11437
+ // src/exulu/auto-decline-stale-approvals.ts
11438
+ var AUTO_DECLINE_REASON = "Automatically declined because the user sent a new message instead of responding to the approval request.";
11439
+ var isPendingApprovalToolPart = (part) => {
11440
+ const candidate = part;
11441
+ return (candidate?.type === "dynamic-tool" || typeof candidate?.type === "string" && candidate.type.startsWith("tool-")) && candidate?.state === "approval-requested" && typeof candidate?.approval?.id === "string";
11442
+ };
11443
+ var autoDeclineStaleApprovals = (messages) => {
11444
+ const declined = [];
11445
+ const reconciled = messages.map((message, index) => {
11446
+ if (index === messages.length - 1 || message.role !== "assistant") return message;
11447
+ if (!message.parts?.some(isPendingApprovalToolPart)) return message;
11448
+ const updated = {
11449
+ ...message,
11450
+ parts: message.parts.map(
11451
+ (part) => isPendingApprovalToolPart(part) ? {
11452
+ ...part,
11453
+ state: "output-denied",
11454
+ approval: { id: part.approval.id, approved: false, reason: AUTO_DECLINE_REASON }
11455
+ } : part
11456
+ )
11457
+ };
11458
+ declined.push(updated);
11459
+ return updated;
11460
+ });
11461
+ return { messages: reconciled, declined };
11462
+ };
11463
+
11298
11464
  // src/exulu/provider.ts
11299
11465
  import { z as z2 } from "zod";
11300
11466
  import {
@@ -11530,7 +11696,9 @@ var ExuluProvider = class {
11530
11696
  agent,
11531
11697
  instructions,
11532
11698
  maxStepCount,
11533
- onTokenUsage
11699
+ onTokenUsage,
11700
+ contextWindow,
11701
+ disabledTools
11534
11702
  }) => {
11535
11703
  console.log(
11536
11704
  "[EXULU] Called generate sync for agent: " + this.name,
@@ -11558,9 +11726,7 @@ var ExuluProvider = class {
11558
11726
  if (messages && session && user) {
11559
11727
  const previousMessages = await getAgentMessages({
11560
11728
  session,
11561
- user: user.id,
11562
- limit: 50,
11563
- page: 1
11729
+ user: user.id
11564
11730
  });
11565
11731
  const previousMessagesContent = previousMessages.map(
11566
11732
  (message) => JSON.parse(message.content)
@@ -11569,6 +11735,12 @@ var ExuluProvider = class {
11569
11735
  // append the new message to the previous messages:
11570
11736
  messages: [...previousMessagesContent, ...messages]
11571
11737
  });
11738
+ const contextBudget = deriveContextBudget(contextWindow);
11739
+ const occupancy = contextOccupancy(messages);
11740
+ if (occupancy >= contextBudget.blockThreshold) {
11741
+ throw new ContextCompactionRequiredError(occupancy, contextBudget);
11742
+ }
11743
+ messages = sliceHistoryAtCheckpoint(messages);
11572
11744
  }
11573
11745
  console.log(
11574
11746
  "[EXULU] Message count for agent: " + this.name,
@@ -11633,6 +11805,34 @@ var ExuluProvider = class {
11633
11805
  if (memoryContext) {
11634
11806
  system += "\n\n" + memoryContext;
11635
11807
  }
11808
+ const tools = await convertExuluToolsToAiSdkTools(
11809
+ currentTools,
11810
+ currentSkills,
11811
+ approvedTools,
11812
+ allExuluTools,
11813
+ toolConfigs,
11814
+ providerapikey,
11815
+ contexts,
11816
+ user,
11817
+ exuluConfig,
11818
+ session,
11819
+ req,
11820
+ project,
11821
+ sessionItems,
11822
+ model,
11823
+ agent,
11824
+ memoryItems,
11825
+ contextWindow,
11826
+ disabledTools
11827
+ );
11828
+ const agenticEntry = currentTools?.find((t) => t.id === "agentic_context_search");
11829
+ const agenticToolKey = agenticEntry ? sanitizeToolName(agenticEntry.name) : void 0;
11830
+ const retrievalGuard = retrievalBudgetGuard(
11831
+ resolveRetrievalCallBudget(toolConfigs),
11832
+ agenticToolKey,
11833
+ Object.keys(tools)
11834
+ );
11835
+ const turnBudget = resolveTurnStepBudget(maxStepCount, agent);
11636
11836
  const includesContextSearchTool = currentTools?.some(
11637
11837
  (tool2) => tool2.name.toLowerCase().includes("context_search") || tool2.id.includes("context_search") || tool2.type === "context"
11638
11838
  );
@@ -11645,12 +11845,12 @@ var ExuluProvider = class {
11645
11845
  system += `
11646
11846
 
11647
11847
 
11648
-
11848
+
11649
11849
  When you use a context search tool, you will include references to the items
11650
11850
  retrieved from the tool call result inline in the response using this exact JSON format
11651
11851
  (all on one line, no line breaks):
11652
11852
  {item_name: <item_name>, item_id: <item_id>, context: <context_id>, chunk_id: <chunk_id>, chunk_index: <chunk_index>}
11653
-
11853
+
11654
11854
  IMPORTANT formatting rules:
11655
11855
  - Do NOT reference just chunks like "Looking at chunk_index 5 and chunk_index 0 from the search result", always use the JSON format above.
11656
11856
  - Use the exact format shown above, all on ONE line
@@ -11658,9 +11858,9 @@ var ExuluProvider = class {
11658
11858
  - Use the context ID from the tool result
11659
11859
  - Include the file/item name, not the full path
11660
11860
  - Separate multiple citations with spaces
11661
-
11861
+
11662
11862
  Example: {item_name: document.pdf, item_id: abc123, context: my-context-id, chunk_id: chunk_456, chunk_index: 0}
11663
-
11863
+
11664
11864
  The citations will be rendered as interactive badges in the UI.
11665
11865
  `;
11666
11866
  }
@@ -11671,12 +11871,12 @@ var ExuluProvider = class {
11671
11871
  When you use a web search tool, you will include references to the results of the tool call result inline in the response using this exact JSON format
11672
11872
  (all on one line, no line breaks):
11673
11873
  {url: <url>, title: <title>, snippet: <snippet>}
11674
-
11874
+
11675
11875
  IMPORTANT formatting rules:
11676
11876
  - Use the exact format shown above, all on ONE line
11677
11877
  - Do NOT use quotes around field names or values
11678
11878
  - Separate multiple results with spaces
11679
-
11879
+
11680
11880
  Example: {url: https://www.google.com, title: Google, snippet: The result of the web search.}
11681
11881
  `;
11682
11882
  }
@@ -11709,29 +11909,12 @@ When a tool execution is not approved by the user, do not retry it unless explic
11709
11909
  system,
11710
11910
  prompt,
11711
11911
  maxRetries: 2,
11712
- tools: await convertExuluToolsToAiSdkTools(
11713
- currentTools,
11714
- currentSkills,
11715
- approvedTools,
11716
- allExuluTools,
11717
- toolConfigs,
11718
- providerapikey,
11719
- contexts,
11720
- user,
11721
- exuluConfig,
11722
- session,
11723
- req,
11724
- project,
11725
- sessionItems,
11726
- model,
11727
- agent,
11728
- memoryItems
11729
- ),
11912
+ tools,
11730
11913
  // Stop after the image_generation tool fires — the widget IS the
11731
11914
  // assistant's response, no follow-up text turn is wanted (same
11732
11915
  // reasoning as question_ask: the UI artifact is the message).
11733
- prepareStep: finalAnswerGuard(maxStepCount ?? resolveMaxStepsFromToolConfigs(toolConfigs) ?? 5),
11734
- stopWhen: [stepCountIs(maxStepCount ?? resolveMaxStepsFromToolConfigs(toolConfigs) ?? 5), hasToolCall("image_generation")]
11916
+ prepareStep: composePrepareSteps(contextGuard(contextWindow), retrievalGuard, finalAnswerGuard(turnBudget)),
11917
+ stopWhen: [stepCountIs(turnBudget), hasToolCall("image_generation")]
11735
11918
  });
11736
11919
  console.log("[EXULU] Output: " + JSON.stringify(output, null, 2));
11737
11920
  const {
@@ -11792,26 +11975,9 @@ When a tool execution is not approved by the user, do not retry it unless explic
11792
11975
  ignoreIncompleteToolCalls: true
11793
11976
  }),
11794
11977
  maxRetries: 2,
11795
- tools: await convertExuluToolsToAiSdkTools(
11796
- currentTools,
11797
- currentSkills,
11798
- approvedTools,
11799
- allExuluTools,
11800
- toolConfigs,
11801
- providerapikey,
11802
- contexts,
11803
- user,
11804
- exuluConfig,
11805
- session,
11806
- req,
11807
- project,
11808
- sessionItems,
11809
- model,
11810
- agent,
11811
- memoryItems
11812
- ),
11813
- prepareStep: finalAnswerGuard(maxStepCount ?? resolveMaxStepsFromToolConfigs(toolConfigs) ?? 5),
11814
- stopWhen: [stepCountIs(maxStepCount ?? resolveMaxStepsFromToolConfigs(toolConfigs) ?? 5), hasToolCall("image_generation")]
11978
+ tools,
11979
+ prepareStep: composePrepareSteps(contextGuard(contextWindow), retrievalGuard, finalAnswerGuard(turnBudget)),
11980
+ stopWhen: [stepCountIs(turnBudget), hasToolCall("image_generation")]
11815
11981
  });
11816
11982
  if (statistics) {
11817
11983
  await Promise.all([
@@ -11863,7 +12029,7 @@ When a tool execution is not approved by the user, do not retry it unless explic
11863
12029
  * - Document files (PDF, DOCX, etc.) -> text parts with extracted content using officeparser
11864
12030
  * - Image files -> image parts (which ARE supported by Responses API)
11865
12031
  */
11866
- async processFilePartsInMessages(messages) {
12032
+ async processFilePartsInMessages(messages, offloadCtx) {
11867
12033
  const processedMessages = await Promise.all(
11868
12034
  messages.map(async (message) => {
11869
12035
  if (message.role !== "user" || !Array.isArray(message.parts)) {
@@ -11908,10 +12074,11 @@ When a tool execution is not approved by the user, do not retry it unless explic
11908
12074
  outputErrorToConsole: false,
11909
12075
  newlineDelimiter: "\n"
11910
12076
  });
12077
+ const guardedText = await guardExtractedFileText(filename, String(extractedText), offloadCtx);
11911
12078
  return {
11912
12079
  type: "text",
11913
12080
  text: `<file file name = "${filename}" >
11914
- ${extractedText}
12081
+ ${guardedText}
11915
12082
  </file>`
11916
12083
  };
11917
12084
  } catch (error) {
@@ -11927,7 +12094,6 @@ ${extractedText}
11927
12094
  ...message,
11928
12095
  parts: processedParts
11929
12096
  };
11930
- console.log("[EXULU] Result: " + JSON.stringify(result, null, 2));
11931
12097
  return result;
11932
12098
  })
11933
12099
  );
@@ -11950,7 +12116,9 @@ ${extractedText}
11950
12116
  exuluConfig,
11951
12117
  instructions,
11952
12118
  req,
11953
- maxStepCount
12119
+ maxStepCount,
12120
+ contextWindow,
12121
+ disabledTools
11954
12122
  }) => {
11955
12123
  if (!this.config) {
11956
12124
  console.error("[EXULU] Config is required for streaming.");
@@ -11971,9 +12139,7 @@ ${extractedText}
11971
12139
  console.log("[EXULU] loading previous messages from session: " + session);
11972
12140
  const previousMessages2 = await getAgentMessages({
11973
12141
  session,
11974
- user: user?.id,
11975
- limit: 50,
11976
- page: 1
12142
+ user: user?.id
11977
12143
  });
11978
12144
  previousMessagesContent = previousMessages2.map((message2) => JSON.parse(message2.content));
11979
12145
  }
@@ -12031,60 +12197,33 @@ ${extractedText}
12031
12197
  messages = messages.filter(
12032
12198
  (message2, index, self) => index === self.findLastIndex((t) => t.id === message2.id)
12033
12199
  );
12034
- messages = await this.processFilePartsInMessages(messages);
12200
+ const { messages: reconciledMessages, declined } = autoDeclineStaleApprovals(messages);
12201
+ messages = reconciledMessages;
12202
+ if (declined.length && session && user) {
12203
+ await saveChat({ session, user: user.id, messages: declined });
12204
+ }
12205
+ messages = await this.processFilePartsInMessages(messages, {
12206
+ contextWindow,
12207
+ sessionID: session,
12208
+ user,
12209
+ exuluConfig
12210
+ });
12211
+ const chronologicalMessages = messages;
12212
+ const contextBudget = deriveContextBudget(contextWindow);
12213
+ const occupancy = contextOccupancy(chronologicalMessages);
12214
+ if (occupancy >= contextBudget.blockThreshold) {
12215
+ console.warn(
12216
+ `[EXULU] Blocking request: occupancy ${occupancy} >= blockThreshold ${contextBudget.blockThreshold} (window ${contextBudget.contextWindow}).`
12217
+ );
12218
+ throw new ContextCompactionRequiredError(occupancy, contextBudget);
12219
+ }
12220
+ messages = sliceHistoryAtCheckpoint(chronologicalMessages);
12035
12221
  const genericContext = "IMPORTANT: \n\n The current date is " + (/* @__PURE__ */ new Date()).toLocaleDateString() + " and the current time is " + (/* @__PURE__ */ new Date()).toLocaleTimeString() + ". If the user does not explicitly provide the current date, for examle when saying ' this weekend', you should assume they are talking with the current date in mind as a reference.";
12036
12222
  let system = instructions || "You are a helpful assistant. When you use a tool to answer a question do not explicitly comment on the result of the tool call unless the user has explicitly you to do something with the result.";
12037
12223
  if (user?.personal_system_prompt?.trim()) {
12038
12224
  system += "\n\nUser preferences:\n" + user.personal_system_prompt.trim();
12039
12225
  }
12040
12226
  system += "\n\n" + genericContext;
12041
- const includesContextSearchTool = currentTools?.some(
12042
- (tool2) => tool2.name.toLowerCase().includes("context_search") || tool2.id.includes("context_search") || tool2.type === "context"
12043
- );
12044
- const includesWebSearchTool = currentTools?.some(
12045
- (tool2) => tool2.name.toLowerCase().includes("web_search") || tool2.id.includes("web_search") || tool2.type === "web_search"
12046
- );
12047
- console.log("[EXULU] Current tools: " + currentTools?.map((tool2) => tool2.name).join("\n"));
12048
- console.log("[EXULU] Includes context search tool: " + includesContextSearchTool);
12049
- console.log("[EXULU] Includes web search tool: " + includesWebSearchTool);
12050
- if (includesContextSearchTool) {
12051
- system += `
12052
-
12053
-
12054
-
12055
- When you use a context search tool, you will include references to the items
12056
- retrieved from the tool call result inline in the response using this exact JSON format
12057
- (all on one line, no line breaks):
12058
- {item_name: <item_name>, item_id: <item_id>, context: <context_id>, chunk_id: <chunk_id>, chunk_index: <chunk_index>}
12059
-
12060
- IMPORTANT formatting rules:
12061
- - Use the exact format shown above, all on ONE line
12062
- - Do NOT use quotes around field names or values
12063
- - Use the context ID from the tool result
12064
- - Include the file/item name, not the full path
12065
- - Separate multiple citations with spaces
12066
-
12067
- Example: {item_name: document.pdf, item_id: abc123, context: my-context-id, chunk_id: chunk_456, chunk_index: 0}
12068
-
12069
- The citations will be rendered as interactive badges in the UI.
12070
- `;
12071
- }
12072
- if (includesWebSearchTool) {
12073
- system += `
12074
-
12075
-
12076
- When you use a web search tool, you will include references to the results of the tool call result inline in the response using this exact JSON format
12077
- (all on one line, no line breaks):
12078
- {url: <url>, title: <title>, snippet: <snippet>}
12079
-
12080
- IMPORTANT formatting rules:
12081
- - Use the exact format shown above, all on ONE line
12082
- - Do NOT use quotes around field names or values
12083
- - Separate multiple results with spaces
12084
-
12085
- Example: {url: https://www.google.com, title: Google, snippet: The result of the web search.}
12086
- `;
12087
- }
12088
12227
  if (currentSkills?.length) {
12089
12228
  const skillsList = currentSkills.map((skill) => {
12090
12229
  const description = (skill.description ?? "").trim();
@@ -12139,6 +12278,11 @@ ${skillsList}
12139
12278
  read them with the readFile tool. Files you produce yourself (via writeFile or via shell
12140
12279
  commands like \`node create_doc.js\`) live in the same place. These files are scoped to
12141
12280
  this single session; they are NOT visible in other sessions, projects, or knowledge bases.
12281
+
12282
+ Note on large outputs: oversized tool outputs and large uploaded documents are automatically
12283
+ truncated in the conversation; the FULL content is saved as a session file (named in the
12284
+ truncation notice, e.g. tool-output-*.txt). Use the read_session_file tool with offset/limit
12285
+ to page through it \u2014 do not ask the user to re-upload.
12142
12286
  `;
12143
12287
  system += `
12144
12288
 
@@ -12162,9 +12306,66 @@ When a tool execution is not approved by the user, do not retry it unless explic
12162
12306
  sessionItems,
12163
12307
  model,
12164
12308
  agent,
12165
- memoryItems
12309
+ memoryItems,
12310
+ contextWindow,
12311
+ disabledTools
12166
12312
  );
12167
12313
  console.log("[EXULU] Converted tools", Object.keys(tools));
12314
+ const includesContextSearchTool = currentTools?.some(
12315
+ (tool2) => tool2.name.toLowerCase().includes("context_search") || tool2.id.includes("context_search") || tool2.type === "context"
12316
+ );
12317
+ const includesWebSearchTool = currentTools?.some(
12318
+ (tool2) => tool2.name.toLowerCase().includes("web_search") || tool2.id.includes("web_search") || tool2.type === "web_search"
12319
+ );
12320
+ console.log("[EXULU] Current tools: " + currentTools?.map((tool2) => tool2.name).join("\n"));
12321
+ console.log("[EXULU] Includes context search tool: " + includesContextSearchTool);
12322
+ console.log("[EXULU] Includes web search tool: " + includesWebSearchTool);
12323
+ if (includesContextSearchTool) {
12324
+ system += `
12325
+
12326
+
12327
+
12328
+ When you use a context search tool, you will include references to the items
12329
+ retrieved from the tool call result inline in the response using this exact JSON format
12330
+ (all on one line, no line breaks):
12331
+ {item_name: <item_name>, item_id: <item_id>, context: <context_id>, chunk_id: <chunk_id>, chunk_index: <chunk_index>}
12332
+
12333
+ IMPORTANT formatting rules:
12334
+ - Use the exact format shown above, all on ONE line
12335
+ - Do NOT use quotes around field names or values
12336
+ - Use the context ID from the tool result
12337
+ - Include the file/item name, not the full path
12338
+ - Separate multiple citations with spaces
12339
+
12340
+ Example: {item_name: document.pdf, item_id: abc123, context: my-context-id, chunk_id: chunk_456, chunk_index: 0}
12341
+
12342
+ The citations will be rendered as interactive badges in the UI.
12343
+ `;
12344
+ }
12345
+ if (includesWebSearchTool) {
12346
+ system += `
12347
+
12348
+
12349
+ When you use a web search tool, you will include references to the results of the tool call result inline in the response using this exact JSON format
12350
+ (all on one line, no line breaks):
12351
+ {url: <url>, title: <title>, snippet: <snippet>}
12352
+
12353
+ IMPORTANT formatting rules:
12354
+ - Use the exact format shown above, all on ONE line
12355
+ - Do NOT use quotes around field names or values
12356
+ - Separate multiple results with spaces
12357
+
12358
+ Example: {url: https://www.google.com, title: Google, snippet: The result of the web search.}
12359
+ `;
12360
+ }
12361
+ const agenticEntry = currentTools?.find((t) => t.id === "agentic_context_search");
12362
+ const agenticToolKey = agenticEntry ? sanitizeToolName(agenticEntry.name) : void 0;
12363
+ const retrievalGuard = retrievalBudgetGuard(
12364
+ resolveRetrievalCallBudget(toolConfigs),
12365
+ agenticToolKey,
12366
+ Object.keys(tools)
12367
+ );
12368
+ const turnBudget = resolveTurnStepBudget(maxStepCount, agent);
12168
12369
  const result = streamText({
12169
12370
  temperature: 0,
12170
12371
  // TODO Make this configurable
@@ -12189,10 +12390,9 @@ When a tool execution is not approved by the user, do not retry it unless explic
12189
12390
  `Chat stream error: ${error instanceof Error ? error.message : JSON.stringify(error)}`
12190
12391
  );
12191
12392
  },
12192
- // provide more loops for skills because they are more complex to execute
12193
- // todo allow configuring this per skill
12194
- prepareStep: finalAnswerGuard(maxStepCount ?? resolveMaxStepsFromToolConfigs(toolConfigs) ?? (currentSkills?.length ? 10 : 5)),
12195
- stopWhen: [stepCountIs(maxStepCount ?? resolveMaxStepsFromToolConfigs(toolConfigs) ?? (currentSkills?.length ? 10 : 5)), hasToolCall("image_generation")]
12393
+ // todo allow configuring the step budget per skill
12394
+ prepareStep: composePrepareSteps(contextGuard(contextWindow), retrievalGuard, finalAnswerGuard(turnBudget)),
12395
+ stopWhen: [stepCountIs(turnBudget), hasToolCall("image_generation")]
12196
12396
  });
12197
12397
  return {
12198
12398
  stream: result,
@@ -12203,19 +12403,14 @@ When a tool execution is not approved by the user, do not retry it unless explic
12203
12403
  };
12204
12404
  var getAgentMessages = async ({
12205
12405
  session,
12206
- user,
12207
- limit,
12208
- page
12406
+ user
12209
12407
  }) => {
12210
12408
  const { db } = await postgresClient();
12211
- console.log(
12212
- "[EXULU] getting agent messages for session: " + session + " and user: " + user + " and page: " + page
12213
- );
12214
- const query = db.from("agent_messages").where({ session, user: user || null }).limit(limit);
12215
- if (page > 0) {
12216
- query.offset((page - 1) * limit);
12217
- }
12218
- const messages = await query;
12409
+ console.log("[EXULU] getting agent messages for session: " + session + " and user: " + user);
12410
+ const messages = await db.from("agent_messages").where({ session, user: user || null }).orderBy([
12411
+ { column: "createdAt", order: "asc" },
12412
+ { column: "id", order: "asc" }
12413
+ ]);
12219
12414
  return messages;
12220
12415
  };
12221
12416
  var getSession = async ({ sessionID }) => {
@@ -12318,6 +12513,151 @@ ${SUGGESTIONS_SYSTEM_PROMPT}` : SUGGESTIONS_SYSTEM_PROMPT;
12318
12513
  };
12319
12514
  };
12320
12515
 
12516
+ // src/exulu/resolve-context-window.ts
12517
+ var resolveContextWindow = async ({
12518
+ modelId,
12519
+ exuluProvider
12520
+ }) => {
12521
+ if (process.env.EXULU_USE_LITELLM === "true") {
12522
+ const entry = await findLiteLLMModel(modelId);
12523
+ const fromCatalog = entry?.max_input_tokens ?? entry?.max_tokens;
12524
+ if (fromCatalog != null && fromCatalog > 0) return fromCatalog;
12525
+ } else if (exuluProvider) {
12526
+ try {
12527
+ const fromProvider = exuluProvider.maxContextLength;
12528
+ if (fromProvider != null && fromProvider > 0) return fromProvider;
12529
+ } catch {
12530
+ }
12531
+ }
12532
+ console.warn(
12533
+ `[EXULU] Unknown context window for model "${modelId}" \u2014 assuming ${DEFAULT_CONTEXT_WINDOW}. Check the LiteLLM catalog / provider template metadata.`
12534
+ );
12535
+ return DEFAULT_CONTEXT_WINDOW;
12536
+ };
12537
+
12538
+ // src/exulu/active-streams.ts
12539
+ var active = /* @__PURE__ */ new Set();
12540
+ var markStreamActive = (sessionID) => {
12541
+ active.add(sessionID);
12542
+ };
12543
+ var clearStreamActive = (sessionID) => {
12544
+ active.delete(sessionID);
12545
+ };
12546
+ var isStreamActive = (sessionID) => active.has(sessionID);
12547
+
12548
+ // src/exulu/compact-session.ts
12549
+ import { randomUUID } from "crypto";
12550
+ import { generateText as generateText6, validateUIMessages as validateUIMessages2 } from "ai";
12551
+ var CompactionInsufficientError = class extends Error {
12552
+ constructor(reason) {
12553
+ super(JSON.stringify({ code: COMPACTION_INSUFFICIENT, message: reason }));
12554
+ this.name = "CompactionInsufficientError";
12555
+ }
12556
+ };
12557
+ var MIN_TAIL_MESSAGES = 2;
12558
+ var SUMMARY_TOOL_OUTPUT_SLICE = 1500;
12559
+ var SUMMARY_TOOL_INPUT_SLICE = 200;
12560
+ var SUMMARY_SYSTEM = `You compress chat histories for an AI assistant. Produce a dense, factual summary of the conversation below. Preserve:
12561
+ - the user's intent and any outstanding requests
12562
+ - key facts, decisions, and constraints
12563
+ - files, artifacts, and session files touched \u2014 ALWAYS keep exact file and item names so they stay retrievable
12564
+ - errors encountered and how they were resolved
12565
+ - pending tasks and the current state of the work
12566
+ Do not invent information. Do not include pleasantries. Write compact prose or bullet points.`;
12567
+ var splitTail = (messages, tailTokenBudget) => {
12568
+ const minTail = Math.min(MIN_TAIL_MESSAGES, messages.length);
12569
+ let cut = messages.length;
12570
+ let tokens = 0;
12571
+ for (let i = messages.length - 1; i >= 0; i--) {
12572
+ const t = estimateMessageTokens(messages[i]);
12573
+ const tailCount = messages.length - i;
12574
+ if (tailCount > minTail && tokens + t > tailTokenBudget) break;
12575
+ tokens += t;
12576
+ cut = i;
12577
+ }
12578
+ return { head: messages.slice(0, cut), tail: messages.slice(cut) };
12579
+ };
12580
+ var serializeForSummary = (messages) => messages.map((m) => {
12581
+ const parts = (m.parts ?? []).map((part) => {
12582
+ const p = part;
12583
+ if (p.type === "text") return p.text ?? "";
12584
+ if (p.type === "file") return `[file: ${p.filename ?? p.url ?? "attachment"}]`;
12585
+ if (p.type === "reasoning" || p.type === "step-start") return "";
12586
+ if (p.type?.startsWith("tool-") || p.type === "dynamic-tool") {
12587
+ const out = p.output?.value ?? p.output;
12588
+ const outText = typeof out === "string" ? out : JSON.stringify(out ?? "");
12589
+ return `[tool ${p.type}: ${JSON.stringify(p.input ?? {}).slice(0, SUMMARY_TOOL_INPUT_SLICE)}] \u2192 ${outText.slice(0, SUMMARY_TOOL_OUTPUT_SLICE)}`;
12590
+ }
12591
+ return "";
12592
+ }).filter(Boolean).join("\n");
12593
+ return `${m.role.toUpperCase()}:
12594
+ ${parts}`;
12595
+ }).join("\n\n");
12596
+ var compactSession = async ({
12597
+ sessionID,
12598
+ user,
12599
+ languageModel,
12600
+ contextWindow,
12601
+ steer,
12602
+ modelId,
12603
+ summarize
12604
+ }) => {
12605
+ const budget = deriveContextBudget(contextWindow);
12606
+ const rows = await getAgentMessages({ session: sessionID, user: user.id });
12607
+ const all = await validateUIMessages2({ messages: rows.map((r) => JSON.parse(r.content)) });
12608
+ const history = sliceHistoryAtCheckpoint(all);
12609
+ const { head, tail } = splitTail(history, budget.compactionTailTokens);
12610
+ if (head.length === 0) {
12611
+ throw new CompactionInsufficientError(
12612
+ "There is nothing left to compact \u2014 the recent messages already form the whole context. Start a new chat instead."
12613
+ );
12614
+ }
12615
+ let corpus = serializeForSummary(head);
12616
+ const originalTokens = estimateTokens(corpus);
12617
+ corpus = truncateToolOutput(corpus, contextWindow, "history", 0.3, Math.floor(budget.usableWindow * 0.8) * 4);
12618
+ const system = steer?.trim() ? `${SUMMARY_SYSTEM}
12619
+
12620
+ Focus especially on: ${steer.trim()}` : SUMMARY_SYSTEM;
12621
+ const doSummarize = summarize ?? (async ({ system: sys, prompt, maxOutputTokens }) => {
12622
+ const { text } = await generateText6({
12623
+ model: languageModel,
12624
+ system: sys,
12625
+ prompt,
12626
+ temperature: 0,
12627
+ maxRetries: 2,
12628
+ maxOutputTokens
12629
+ });
12630
+ return text;
12631
+ });
12632
+ const summary = await doSummarize({ system, prompt: corpus, maxOutputTokens: budget.summaryBudgetTokens });
12633
+ const summaryTokens = estimateTokens(summary);
12634
+ let tailTokens = 0;
12635
+ for (const m of tail) tailTokens += estimateMessageTokens(m);
12636
+ const occupancyEstimate = summaryTokens + tailTokens;
12637
+ if (occupancyEstimate >= budget.blockThreshold) {
12638
+ throw new CompactionInsufficientError(
12639
+ "Compacting cannot shrink this conversation below the context limit \u2014 a recent message or output is too large by itself. Start a new chat."
12640
+ );
12641
+ }
12642
+ const compaction = {
12643
+ coversUpTo: head[head.length - 1].id,
12644
+ originalTokens,
12645
+ summaryTokens,
12646
+ occupancyEstimate,
12647
+ ...steer?.trim() ? { steer: steer.trim() } : {}
12648
+ };
12649
+ const checkpoint = {
12650
+ id: `compaction_${randomUUID()}`,
12651
+ role: "user",
12652
+ parts: [{ type: "text", text: `[Conversation summary \u2014 earlier messages were compacted]
12653
+
12654
+ ${summary}` }],
12655
+ metadata: { compaction }
12656
+ };
12657
+ await saveChat({ session: sessionID, user: user.id, messages: [checkpoint], ...modelId ? { model: modelId } : {} });
12658
+ return { checkpoint, occupancyEstimate, originalTokens, summaryTokens };
12659
+ };
12660
+
12321
12661
  // src/exulu/transcribe.ts
12322
12662
  var TranscriptionError = class extends Error {
12323
12663
  constructor(upstreamStatus, message) {
@@ -12683,7 +13023,7 @@ function checkApiKeyScope(user, agentId) {
12683
13023
  import "express";
12684
13024
  import {
12685
13025
  streamText as streamText2,
12686
- generateText as generateText6,
13026
+ generateText as generateText7,
12687
13027
  stepCountIs as stepCountIs2,
12688
13028
  jsonSchema
12689
13029
  } from "ai";
@@ -12773,7 +13113,7 @@ function transformCompletion(text, inputTokens, outputTokens, ctx) {
12773
13113
  }
12774
13114
 
12775
13115
  // src/exulu/openai-gateway.ts
12776
- import { randomUUID } from "crypto";
13116
+ import { randomUUID as randomUUID2 } from "crypto";
12777
13117
  import "crypto-js";
12778
13118
  import express from "express";
12779
13119
  function convertOpenAIToolsToAiSdkTools(tools) {
@@ -13087,6 +13427,10 @@ var registerOpenAIGatewayRoutes = async (app, providers, tools, contexts, config
13087
13427
  }
13088
13428
  const providerapikey = resolved.apiKey;
13089
13429
  const languageModel = resolved.languageModel;
13430
+ const contextWindow = await resolveContextWindow({
13431
+ modelId: resolved.model.id,
13432
+ exuluProvider: isLiteLLMEnabled() ? void 0 : resolved.exuluProvider
13433
+ });
13090
13434
  const disabledTools = req.body.disabledTools ?? [];
13091
13435
  const enabledTools = await getEnabledTools(
13092
13436
  agent,
@@ -13111,12 +13455,50 @@ var registerOpenAIGatewayRoutes = async (app, providers, tools, contexts, config
13111
13455
  project?.id,
13112
13456
  void 0,
13113
13457
  languageModel,
13114
- agent
13458
+ agent,
13459
+ void 0,
13460
+ contextWindow,
13461
+ disabledTools
13462
+ );
13463
+ const gatewayAgenticEntry = enabledTools?.find((t) => t.id === "agentic_context_search");
13464
+ const gatewayAgenticKey = gatewayAgenticEntry ? sanitizeToolName(gatewayAgenticEntry.name) : void 0;
13465
+ const gatewayRetrievalGuard = retrievalBudgetGuard(
13466
+ resolveRetrievalCallBudget(agent.tools),
13467
+ gatewayAgenticKey,
13468
+ Object.keys(convertedTools)
13115
13469
  );
13470
+ const turnBudget = resolveTurnStepBudget(void 0, agent);
13116
13471
  const clientTools = Array.isArray(req.body.tools) ? req.body.tools : [];
13117
13472
  const activeTools = clientTools.length > 0 ? convertOpenAIToolsToAiSdkTools(clientTools) : convertedTools;
13118
13473
  const openaiMessages = req.body.messages ?? [];
13119
13474
  const { systemPrompt: requestSystemPrompt, coreMessages } = convertOpenAIMessagesToModelMessages(openaiMessages);
13475
+ const gatewayBudget = deriveContextBudget(contextWindow);
13476
+ const IMAGE_TOKEN_ALLOWANCE = 1e3;
13477
+ let imageCount = 0;
13478
+ const textOnlyMessages = openaiMessages.map((m) => {
13479
+ if (!Array.isArray(m.content)) return m;
13480
+ return {
13481
+ ...m,
13482
+ content: m.content.map((p) => {
13483
+ if (p && typeof p === "object" && "image_url" in p && p.image_url) {
13484
+ imageCount += 1;
13485
+ return { ...p, image_url: { url: "[image]" } };
13486
+ }
13487
+ return p;
13488
+ })
13489
+ };
13490
+ });
13491
+ const promptTokens = estimateTokens(JSON.stringify(textOnlyMessages)) + imageCount * IMAGE_TOKEN_ALLOWANCE;
13492
+ if (promptTokens >= gatewayBudget.blockThreshold) {
13493
+ res.status(400).json({
13494
+ error: {
13495
+ message: `This request is ~${promptTokens.toLocaleString("en-US")} tokens, which exceeds the model's usable context window (${gatewayBudget.usableWindow.toLocaleString("en-US")} tokens). Reduce the conversation history.`,
13496
+ type: "invalid_request_error",
13497
+ code: "context_length_exceeded"
13498
+ }
13499
+ });
13500
+ return;
13501
+ }
13120
13502
  const agentInstructions = agent.instructions ?? "";
13121
13503
  const systemParts = [
13122
13504
  agentInstructions ? `You are an agent named: ${agent.name}
@@ -13126,7 +13508,7 @@ ${project.description}` : ""}` : "",
13126
13508
  requestSystemPrompt
13127
13509
  ].filter(Boolean);
13128
13510
  const systemPrompt = systemParts.join("\n\n");
13129
- const completionId = `chatcmpl-${randomUUID()}`;
13511
+ const completionId = `chatcmpl-${randomUUID2()}`;
13130
13512
  const created = Math.floor(Date.now() / 1e3);
13131
13513
  const hasTools = Object.keys(activeTools).length > 0;
13132
13514
  const ctx = { completionId, created, modelId };
@@ -13140,7 +13522,8 @@ ${project.description}` : ""}` : "",
13140
13522
  messages: coreMessages,
13141
13523
  tools: hasTools ? activeTools : void 0,
13142
13524
  maxRetries: 2,
13143
- stopWhen: clientTools.length > 0 ? void 0 : [stepCountIs2(5)],
13525
+ prepareStep: clientTools.length > 0 ? void 0 : composePrepareSteps(contextGuard(contextWindow), gatewayRetrievalGuard, finalAnswerGuard(turnBudget)),
13526
+ stopWhen: clientTools.length > 0 ? void 0 : [stepCountIs2(turnBudget)],
13144
13527
  onError: (error) => {
13145
13528
  console.error("[OPENAI GATEWAY] stream error:", error);
13146
13529
  }
@@ -13173,13 +13556,14 @@ ${project.description}` : ""}` : "",
13173
13556
  const usage = await result.usage;
13174
13557
  await writeStatistics(agent, project, user, usage.inputTokens ?? 0, usage.outputTokens ?? 0);
13175
13558
  } else {
13176
- const { text, usage } = await generateText6({
13559
+ const { text, usage } = await generateText7({
13177
13560
  model: languageModel,
13178
13561
  system: systemPrompt || void 0,
13179
13562
  messages: coreMessages,
13180
13563
  tools: hasTools ? activeTools : void 0,
13181
13564
  maxRetries: 2,
13182
- stopWhen: clientTools.length > 0 ? void 0 : [stepCountIs2(5)]
13565
+ prepareStep: clientTools.length > 0 ? void 0 : composePrepareSteps(contextGuard(contextWindow), gatewayRetrievalGuard, finalAnswerGuard(turnBudget)),
13566
+ stopWhen: clientTools.length > 0 ? void 0 : [stepCountIs2(turnBudget)]
13183
13567
  });
13184
13568
  res.json(transformCompletion(text, usage.inputTokens ?? 0, usage.outputTokens ?? 0, ctx));
13185
13569
  await writeStatistics(agent, project, user, usage.inputTokens ?? 0, usage.outputTokens ?? 0);
@@ -13677,7 +14061,7 @@ Mood: friendly and intelligent.
13677
14061
  });
13678
14062
  return;
13679
14063
  }
13680
- const uuid = randomUUID2();
14064
+ const uuid = randomUUID3();
13681
14065
  const image_url = await uploadFile(Buffer.from(image_base64, "base64"), `${uuid}.png`, config, {
13682
14066
  contentType: "image/png"
13683
14067
  }, authenticationResult.user?.id, void 0, true);
@@ -13881,6 +14265,10 @@ Mood: friendly and intelligent.
13881
14265
  const providerapikey = resolved.apiKey;
13882
14266
  const resolvedLanguageModel = resolved.languageModel;
13883
14267
  const resolvedModelId = resolved.model.id;
14268
+ const contextWindow = await resolveContextWindow({
14269
+ modelId: resolved.model.id,
14270
+ exuluProvider: isLiteLLMEnabled() ? void 0 : resolved.exuluProvider
14271
+ });
13884
14272
  if (!!headers.stream) {
13885
14273
  const statistics = {
13886
14274
  label: agent.name,
@@ -13899,27 +14287,46 @@ Mood: friendly and intelligent.
13899
14287
  const instructions = customInstructions ? `${agent.instructions}
13900
14288
 
13901
14289
  ${customInstructions}` : agent.instructions;
13902
- const result = await provider.generateStream({
13903
- contexts,
13904
- agent,
13905
- user,
13906
- instructions,
13907
- session: headers.session,
13908
- message,
13909
- previousMessages,
13910
- currentTools: enabledTools,
13911
- currentSkills: enabledSkills,
13912
- approvedTools,
13913
- allExuluTools: tools,
13914
- languageModel: resolvedLanguageModel,
13915
- providerapikey,
13916
- toolConfigs: agent.tools,
13917
- exuluConfig: config,
13918
- req
13919
- });
14290
+ if (headers.session) markStreamActive(headers.session);
14291
+ let result;
14292
+ try {
14293
+ result = await provider.generateStream({
14294
+ contexts,
14295
+ agent,
14296
+ user,
14297
+ instructions,
14298
+ session: headers.session,
14299
+ message,
14300
+ previousMessages,
14301
+ currentTools: enabledTools,
14302
+ currentSkills: enabledSkills,
14303
+ approvedTools,
14304
+ allExuluTools: tools,
14305
+ languageModel: resolvedLanguageModel,
14306
+ providerapikey,
14307
+ toolConfigs: agent.tools,
14308
+ exuluConfig: config,
14309
+ req,
14310
+ contextWindow,
14311
+ disabledTools
14312
+ });
14313
+ } catch (err) {
14314
+ if (headers.session) clearStreamActive(headers.session);
14315
+ if (err instanceof ContextCompactionRequiredError) {
14316
+ res.status(413).send(err.message);
14317
+ return;
14318
+ }
14319
+ throw err;
14320
+ }
13920
14321
  result.stream.consumeStream();
13921
14322
  result.stream.pipeUIMessageStreamToResponse(res, {
13922
14323
  messageMetadata: ({ part }) => {
14324
+ if (part.type === "finish-step") {
14325
+ return {
14326
+ lastStepInputTokens: part.usage.inputTokens,
14327
+ lastStepOutputTokens: part.usage.outputTokens
14328
+ };
14329
+ }
13923
14330
  if (part.type === "finish") {
13924
14331
  return {
13925
14332
  totalTokens: part.totalUsage.totalTokens,
@@ -13936,22 +14343,20 @@ ${customInstructions}` : agent.instructions;
13936
14343
  sendSources: true,
13937
14344
  onError: (error) => {
13938
14345
  console.error("[EXULU] chat response error.", error);
13939
- if (error == null) {
13940
- return "unknown error";
13941
- }
13942
- if (typeof error === "string") {
13943
- return error;
13944
- }
13945
- if (error instanceof Error) {
13946
- return error.message;
13947
- }
13948
- return JSON.stringify(error);
14346
+ if (headers.session) clearStreamActive(headers.session);
14347
+ let message2;
14348
+ if (error == null) message2 = "unknown error";
14349
+ else if (typeof error === "string") message2 = error;
14350
+ else if (error instanceof Error) message2 = error.message;
14351
+ else message2 = JSON.stringify(error);
14352
+ return mapStreamErrorMessage(message2);
13949
14353
  },
13950
14354
  generateMessageId: createIdGenerator({
13951
14355
  prefix: "msg_",
13952
14356
  size: 16
13953
14357
  }),
13954
14358
  onFinish: async ({ messages, isContinuation, isAborted, responseMessage }) => {
14359
+ if (headers.session) clearStreamActive(headers.session);
13955
14360
  console.log(
13956
14361
  "[EXULU] onFinish",
13957
14362
  messages?.map((msg) => msg.parts?.map((part) => part.type === "text" ? part.text : null)).join("\n")
@@ -14013,33 +14418,129 @@ ${customInstructions}` : agent.instructions;
14013
14418
  const instructions = customInstructions ? `${agent.instructions}
14014
14419
 
14015
14420
  ${customInstructions}` : agent.instructions;
14016
- const response = await provider.generateSync({
14017
- contexts,
14018
- agent,
14019
- user,
14020
- req,
14021
- instructions,
14022
- session: headers.session,
14023
- inputMessages: [req.body.message],
14024
- currentTools: enabledTools,
14025
- currentSkills: enabledSkills,
14026
- allExuluTools: tools,
14027
- languageModel: resolvedLanguageModel,
14028
- providerapikey,
14029
- exuluConfig: config,
14030
- toolConfigs: agent.tools,
14031
- statistics: {
14032
- label: agent.name,
14033
- trigger: "agent"
14034
- },
14035
- onTokenUsage: async ({ inputTokens, outputTokens }) => {
14421
+ let response;
14422
+ try {
14423
+ response = await provider.generateSync({
14424
+ contexts,
14425
+ agent,
14426
+ user,
14427
+ req,
14428
+ instructions,
14429
+ session: headers.session,
14430
+ inputMessages: [req.body.message],
14431
+ currentTools: enabledTools,
14432
+ currentSkills: enabledSkills,
14433
+ allExuluTools: tools,
14434
+ languageModel: resolvedLanguageModel,
14435
+ providerapikey,
14436
+ exuluConfig: config,
14437
+ toolConfigs: agent.tools,
14438
+ contextWindow,
14439
+ disabledTools,
14440
+ statistics: {
14441
+ label: agent.name,
14442
+ trigger: "agent"
14443
+ },
14444
+ onTokenUsage: async ({ inputTokens, outputTokens }) => {
14445
+ }
14446
+ });
14447
+ } catch (err) {
14448
+ if (err instanceof ContextCompactionRequiredError) {
14449
+ res.status(413).send(err.message);
14450
+ return;
14036
14451
  }
14037
- });
14452
+ throw err;
14453
+ }
14038
14454
  res.status(200).json(response);
14039
14455
  return;
14040
14456
  }
14041
14457
  });
14042
14458
  };
14459
+ const registerAgentCompactRoute = (slug) => {
14460
+ app.post(slug + "/:instance", async (req, res) => {
14461
+ const instance = req.params.instance;
14462
+ if (!instance) {
14463
+ res.status(400).json({ message: "Missing instance in request." });
14464
+ return;
14465
+ }
14466
+ const sessionID = req.headers["session"] || null;
14467
+ if (!sessionID) {
14468
+ res.status(400).json({ message: "Missing session header." });
14469
+ return;
14470
+ }
14471
+ const agent = await exuluApp.get().agent(instance);
14472
+ if (!agent) {
14473
+ res.status(404).json({ message: "Agent with id " + instance + " not found." });
14474
+ return;
14475
+ }
14476
+ const authenticationResult = await requestValidators.authenticate(req);
14477
+ if (!authenticationResult.user?.id) {
14478
+ res.status(authenticationResult.code || 401).json({ detail: `${authenticationResult.message}` });
14479
+ return;
14480
+ }
14481
+ const user = authenticationResult.user;
14482
+ const hasAccessToAgent = await checkRecordAccess(agent, "read", user);
14483
+ if (!hasAccessToAgent) {
14484
+ res.status(401).json({ message: "You don't have access to this agent." });
14485
+ return;
14486
+ }
14487
+ const { db } = await postgresClient();
14488
+ const sessionRow = await db.from("agent_sessions").where({ id: sessionID }).first();
14489
+ if (!sessionRow) {
14490
+ res.status(404).json({ message: "Session not found for session ID: " + sessionID });
14491
+ return;
14492
+ }
14493
+ const hasAccessToSession = await checkRecordAccess(sessionRow, "write", user);
14494
+ if (!hasAccessToSession) {
14495
+ res.status(401).json({ message: "You don't have access to this session." });
14496
+ return;
14497
+ }
14498
+ if (isStreamActive(sessionID)) {
14499
+ res.status(409).json({ message: "A response is still streaming for this session \u2014 try again when it finishes." });
14500
+ return;
14501
+ }
14502
+ const overrideModelId = req.headers["x-exulu-model-override"];
14503
+ const modelId = overrideModelId ?? agent.model;
14504
+ if (!modelId) {
14505
+ res.status(400).json({ message: `Agent ${agent.name} (${agent.id}) has no model configured.` });
14506
+ return;
14507
+ }
14508
+ let resolved;
14509
+ try {
14510
+ resolved = await resolveModel({ modelId, user, providers, agent });
14511
+ } catch (err) {
14512
+ if (err instanceof ResolveModelError) {
14513
+ const status = err.code === "MODEL_FORBIDDEN" ? 403 : 400;
14514
+ res.status(status).json({ message: err.message, code: err.code });
14515
+ return;
14516
+ }
14517
+ throw err;
14518
+ }
14519
+ const contextWindow = await resolveContextWindow({
14520
+ modelId: resolved.model.id,
14521
+ exuluProvider: isLiteLLMEnabled() ? void 0 : resolved.exuluProvider
14522
+ });
14523
+ const steer = typeof req.body?.steer === "string" ? req.body.steer : void 0;
14524
+ try {
14525
+ const result = await compactSession({
14526
+ sessionID,
14527
+ user,
14528
+ languageModel: resolved.languageModel,
14529
+ contextWindow,
14530
+ steer,
14531
+ modelId: resolved.model.id
14532
+ });
14533
+ res.json(result);
14534
+ } catch (err) {
14535
+ if (err instanceof CompactionInsufficientError) {
14536
+ res.status(422).send(err.message);
14537
+ return;
14538
+ }
14539
+ console.error("[EXULU] compactSession failed.", err);
14540
+ res.status(500).json({ message: err instanceof Error ? err.message : "Compaction failed." });
14541
+ }
14542
+ });
14543
+ };
14043
14544
  providers.forEach((provider) => {
14044
14545
  const slug = provider.slug;
14045
14546
  if (!slug) return;
@@ -14048,6 +14549,14 @@ ${customInstructions}` : agent.instructions;
14048
14549
  if (isLiteLLMEnabled() && providers.length > 0) {
14049
14550
  registerAgentRunRoute("/agents/litellm/run", providers[0]);
14050
14551
  }
14552
+ providers.forEach((provider) => {
14553
+ const slug = provider.slug;
14554
+ if (!slug) return;
14555
+ registerAgentCompactRoute(slug.replace(/\/run$/, "/compact"));
14556
+ });
14557
+ if (isLiteLLMEnabled() && providers.length > 0) {
14558
+ registerAgentCompactRoute("/agents/litellm/compact");
14559
+ }
14051
14560
  app.post("/agents/suggestions/:agentId", async (req, res) => {
14052
14561
  const agentId = req.params.agentId;
14053
14562
  if (!agentId) {
@@ -14399,7 +14908,7 @@ ${customInstructions}` : agent.instructions;
14399
14908
  const keys = [];
14400
14909
  const revisedPrompts = [];
14401
14910
  for (const img of images) {
14402
- const filename = `${randomUUID2()}.${img.extension}`;
14911
+ const filename = `${randomUUID3()}.${img.extension}`;
14403
14912
  const key = `sessions/${sessionId}/images/${toolCallId}/${filename}`;
14404
14913
  const fullKey = await uploadFile(
14405
14914
  img.buffer,
@@ -14687,7 +15196,7 @@ ${style.markdown}` : params.prompt;
14687
15196
  (d) => `- ${d.presignedUrl} (prompt: "${d.prompt}", model: ${d.model}${d.styleName ? `, style: ${d.styleName}` : ""})`
14688
15197
  );
14689
15198
  const messageText = "The user generated and selected the following image(s) in this chat:\n" + lines.join("\n");
14690
- const messageId = randomUUID2();
15199
+ const messageId = randomUUID3();
14691
15200
  const uiMessage = {
14692
15201
  id: messageId,
14693
15202
  role: "system",
@@ -15615,7 +16124,7 @@ ${style.markdown}` : params.prompt;
15615
16124
  res.status(404).json({ detail: "Skill not found." });
15616
16125
  return;
15617
16126
  }
15618
- const stagingKey = `user_${authResult.user.id}/skills/_staging/${randomUUID2()}${extension}`;
16127
+ const stagingKey = `user_${authResult.user.id}/skills/_staging/${randomUUID3()}${extension}`;
15619
16128
  const fullKey = config.fileUploads?.s3prefix ? `${config.fileUploads.s3prefix.replace(/\/$/, "")}/${stagingKey}` : stagingKey;
15620
16129
  const uploadUrl = await getS3SignedUploadUrl(fullKey, contentType, config);
15621
16130
  res.json({ uploadUrl, stagingKey });
@@ -16646,7 +17155,7 @@ function buildUnifiedDiff(fromLines, toLines, fromLabel, toLabel) {
16646
17155
 
16647
17156
  // src/mcp/index.ts
16648
17157
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16649
- import { randomUUID as randomUUID3 } from "crypto";
17158
+ import { randomUUID as randomUUID4 } from "crypto";
16650
17159
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
16651
17160
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
16652
17161
  import "express";
@@ -16751,7 +17260,7 @@ var ExuluMCP = class {
16751
17260
  throw new Error("Tool not found in converted tools array.");
16752
17261
  }
16753
17262
  const iterator = await convertedTool.execute(inputs, {
16754
- toolCallId: tool2.id + "_" + randomUUID3(),
17263
+ toolCallId: tool2.id + "_" + randomUUID4(),
16755
17264
  messages: []
16756
17265
  });
16757
17266
  let result;
@@ -16943,7 +17452,7 @@ var ExuluMCP = class {
16943
17452
  transport = this.transports[sessionId];
16944
17453
  } else if (!sessionId && isInitializeRequest(req.body)) {
16945
17454
  transport = new StreamableHTTPServerTransport({
16946
- sessionIdGenerator: () => randomUUID3(),
17455
+ sessionIdGenerator: () => randomUUID4(),
16947
17456
  onsessioninitialized: (sessionId2) => {
16948
17457
  this.transports[sessionId2] = transport;
16949
17458
  }
@@ -17924,7 +18433,7 @@ var ExuluEval = class {
17924
18433
 
17925
18434
  // src/templates/evals/index.ts
17926
18435
  import { z as z5 } from "zod";
17927
- import { generateText as generateText7, Output as Output2 } from "ai";
18436
+ import { generateText as generateText8, Output as Output2 } from "ai";
17928
18437
  var llmAsJudgeEval = () => {
17929
18438
  if (process.env.REDIS_HOST?.length && process.env.REDIS_PORT?.length) {
17930
18439
  return new ExuluEval({
@@ -17969,7 +18478,7 @@ var llmAsJudgeEval = () => {
17969
18478
  rbacBypass: true
17970
18479
  });
17971
18480
  console.log("[EXULU] prompt", prompt);
17972
- const { output } = await generateText7({
18481
+ const { output } = await generateText8({
17973
18482
  temperature: 0,
17974
18483
  model: resolved.languageModel,
17975
18484
  system: "",
@@ -18392,7 +18901,7 @@ After asking a question, use the Question Read tool to check if the user has ans
18392
18901
 
18393
18902
  // src/templates/tools/question/question-ask.ts
18394
18903
  import z7 from "zod";
18395
- import { randomUUID as randomUUID4 } from "crypto";
18904
+ import { randomUUID as randomUUID5 } from "crypto";
18396
18905
  var AnswerOptionSchema = z7.object({
18397
18906
  id: z7.string().describe("Unique identifier for the answer option"),
18398
18907
  text: z7.string().describe("The text of the answer option")
@@ -18446,15 +18955,15 @@ var QuestionAskTool = new ExuluTool({
18446
18955
  throw new Error("You don't have access to this session " + session.id + ".");
18447
18956
  }
18448
18957
  const answerOptionsWithIds = answerOptions.map((text) => ({
18449
- id: randomUUID4(),
18958
+ id: randomUUID5(),
18450
18959
  text
18451
18960
  }));
18452
18961
  answerOptionsWithIds.push({
18453
- id: randomUUID4(),
18962
+ id: randomUUID5(),
18454
18963
  text: "None of the above..."
18455
18964
  });
18456
18965
  const newQuestion = {
18457
- id: randomUUID4(),
18966
+ id: randomUUID5(),
18458
18967
  question,
18459
18968
  answerOptions: answerOptionsWithIds,
18460
18969
  status: "pending"
@@ -21235,10 +21744,10 @@ var MarkdownChunker = class {
21235
21744
  // ee/python/documents/processing/doc_processor.ts
21236
21745
  import * as fs3 from "fs";
21237
21746
  import * as path from "path";
21238
- import { generateText as generateText8, Output as Output3 } from "ai";
21747
+ import { generateText as generateText9, Output as Output3 } from "ai";
21239
21748
  import { z as z12 } from "zod";
21240
21749
  import pLimit from "p-limit";
21241
- import { randomUUID as randomUUID5 } from "crypto";
21750
+ import { randomUUID as randomUUID6 } from "crypto";
21242
21751
  import * as mammoth from "mammoth";
21243
21752
  import TurndownService from "turndown";
21244
21753
  import WordExtractor from "word-extractor";
@@ -21692,7 +22201,7 @@ If the page contains a flow-chart, schematic, technical drawing or control board
21692
22201
 
21693
22202
  ### 7. Only populate \`corrected_text\` when \`needs_correction\` is true. If the OCR output is accurate, return \`needs_correction: false\` and \`corrected_content: null\`.
21694
22203
  `;
21695
- const result = await generateText8({
22204
+ const result = await generateText9({
21696
22205
  model,
21697
22206
  output: Output3.object({
21698
22207
  schema: z12.object({
@@ -22113,7 +22622,7 @@ var loadFile = async (file, name, tempDir) => {
22113
22622
  if (!fileType) {
22114
22623
  throw new Error("[EXULU] File name does not include extension, extension is required for document processing.");
22115
22624
  }
22116
- const UUID = randomUUID5();
22625
+ const UUID = randomUUID6();
22117
22626
  let buffer;
22118
22627
  if (Buffer.isBuffer(file)) {
22119
22628
  filePath = path.join(tempDir, `${UUID}.${fileType}`);
@@ -22143,7 +22652,7 @@ async function documentProcessor({
22143
22652
  if (!license["advanced-document-processing"]) {
22144
22653
  throw new Error("Advanced document processing is an enterprise feature, please add a valid Exulu enterprise license key to use it.");
22145
22654
  }
22146
- const uuid = randomUUID5();
22655
+ const uuid = randomUUID6();
22147
22656
  const tempDir = path.join(process.cwd(), "temp", uuid);
22148
22657
  const localFilesAndFoldersToDelete = [tempDir];
22149
22658
  console.log(`[EXULU] Temporary directory for processing document ${name}: ${tempDir}`);