Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/api/health.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const Database = require("better-sqlite3");
const { invokeModel } = require("../clients/databricks");

Check failure on line 2 in src/api/health.js

View workflow job for this annotation

GitHub Actions / Run Tests (20.x)

'invokeModel' is assigned a value but never used

Check failure on line 2 in src/api/health.js

View workflow job for this annotation

GitHub Actions / Run Tests (22.x)

'invokeModel' is assigned a value but never used
const logger = require("../logger");
const config = require("../config");

Expand Down Expand Up @@ -228,8 +228,16 @@
isShuttingDown = value;
}

/**
* Get shutting down flag
*/
function getShuttingDown() {
return isShuttingDown;
}

module.exports = {
livenessCheck,
readinessCheck,
setShuttingDown,
getShuttingDown,
};
28 changes: 24 additions & 4 deletions src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,17 @@ function resolveConfigPath(targetPath) {

const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "azure-openai", "openai", "llamacpp", "lmstudio", "bedrock", "zai", "vertex"]);
const rawModelProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
const modelProvider = SUPPORTED_MODEL_PROVIDERS.has(rawModelProvider)
? rawModelProvider
: "databricks";

// Validate MODEL_PROVIDER early with a clear error message
if (!SUPPORTED_MODEL_PROVIDERS.has(rawModelProvider)) {
const supportedList = Array.from(SUPPORTED_MODEL_PROVIDERS).sort().join(", ");
throw new Error(
`Unsupported MODEL_PROVIDER: "${process.env.MODEL_PROVIDER}". ` +
`Valid options are: ${supportedList}`
);
}

const modelProvider = rawModelProvider;

const rawBaseUrl = trimTrailingSlash(process.env.DATABRICKS_API_BASE);
const apiKey = process.env.DATABRICKS_API_KEY;
Expand Down Expand Up @@ -141,7 +149,19 @@ const openRouterMaxToolsForRouting = Number.parseInt(
process.env.OPENROUTER_MAX_TOOLS_FOR_ROUTING ?? "15",
10
);
const fallbackProvider = (process.env.FALLBACK_PROVIDER ?? "databricks").toLowerCase();

const rawFallbackProvider = (process.env.FALLBACK_PROVIDER ?? "databricks").toLowerCase();

// Validate FALLBACK_PROVIDER early with a clear error message
if (!SUPPORTED_MODEL_PROVIDERS.has(rawFallbackProvider)) {
const supportedList = Array.from(SUPPORTED_MODEL_PROVIDERS).sort().join(", ");
throw new Error(
`Unsupported FALLBACK_PROVIDER: "${process.env.FALLBACK_PROVIDER}". ` +
`Valid options are: ${supportedList}`
);
}

const fallbackProvider = rawFallbackProvider;

// Tool execution mode: server (default), client, or passthrough
const toolExecutionMode = (process.env.TOOL_EXECUTION_MODE ?? "server").toLowerCase();
Expand Down
6 changes: 5 additions & 1 deletion src/memory/extractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,11 @@ async function extractMemories(assistantResponse, conversationMessages, context

for (const entityName of entities) {
// Track entity
store.trackEntity('code', entityName, { source: 'extraction' });
store.trackEntity({
type: 'code',
name: entityName,
context: { source: 'extraction' }
});

const memory = await createMemoryWithSurprise({
content: `Entity: ${entityName}`,
Expand Down
14 changes: 9 additions & 5 deletions src/memory/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,20 @@ function prepareFTS5Query(query) {
// Step 3: Check if query contains FTS5 operators (AND, OR, NOT)
const hasFTS5Operators = /\b(AND|OR|NOT)\b/i.test(cleaned);

// Step 4: Remove or escape remaining FTS5 special characters
// Characters: * ( ) < > - : [ ]
// Strategy: Remove them since they're rarely useful in memory search
cleaned = cleaned.replace(/[*()<>\-:\[\]]/g, ' ');
// Step 4: ENHANCED - Remove ALL special characters that could break FTS5
// Keep only: letters, numbers, spaces
// Remove: * ( ) < > - : [ ] | , + = ? ! ; / \ @ # $ % ^ & { }
cleaned = cleaned.replace(/[*()<>\-:\[\]|,+=?!;\/\\@#$%^&{}]/g, ' ');
cleaned = cleaned.replace(/\s+/g, ' ').trim();

// Step 5: Escape double quotes (FTS5 uses "" for literal quote)
cleaned = cleaned.replace(/"/g, '""');

// Step 6: Wrap in quotes for phrase search (safest approach)
// Step 6: Additional safety - remove any remaining non-alphanumeric except spaces
cleaned = cleaned.replace(/[^\w\s""]/g, ' ');
cleaned = cleaned.replace(/\s+/g, ' ').trim();

// Step 7: Wrap in quotes for phrase search (safest approach)
if (!hasFTS5Operators) {
// Treat as literal phrase search
cleaned = `"${cleaned}"`;
Expand Down
30 changes: 30 additions & 0 deletions src/orchestrator/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const systemPrompt = require("../prompts/system");
const historyCompression = require("../context/compression");
const tokenBudget = require("../context/budget");
const { classifyRequestType, selectToolsSmartly } = require("../tools/smart-selection");
const { getShuttingDown } = require("../api/health");

const DROP_KEYS = new Set([
"provider",
Expand Down Expand Up @@ -1175,6 +1176,35 @@ async function runAgentLoop({
break;
}

// Check if system is shutting down (Ctrl+C or SIGTERM)
if (getShuttingDown()) {
logger.info(
{
sessionId: session?.id ?? null,
steps,
toolCallsExecuted,
durationMs: Date.now() - start,
},
"Agent loop interrupted - system shutting down",
);

return {
response: {
status: 503,
body: {
error: {
type: "service_unavailable",
message: "Service is shutting down. Request was interrupted gracefully.",
},
},
terminationReason: "shutdown",
},
steps,
durationMs: Date.now() - start,
terminationReason: "shutdown",
};
}

steps += 1;
logger.debug(
{
Expand Down
8 changes: 4 additions & 4 deletions src/server/shutdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ class ShutdownManager {
*/
setupSignalHandlers() {
// Handle SIGTERM (Kubernetes, Docker, etc.)
process.on("SIGTERM", () => {
process.on("SIGTERM", async () => {
logger.info("Received SIGTERM, starting graceful shutdown");
this.shutdown("SIGTERM");
await this.shutdown("SIGTERM");
});

// Handle SIGINT (Ctrl+C)
process.on("SIGINT", () => {
process.on("SIGINT", async () => {
logger.info("Received SIGINT, starting graceful shutdown");
this.shutdown("SIGINT");
await this.shutdown("SIGINT");
});

// Handle uncaught exceptions
Expand Down
207 changes: 207 additions & 0 deletions test/config-validation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
const assert = require("assert");
const { describe, it, beforeEach, afterEach } = require("node:test");

describe("Config Validation Tests", () => {
let originalEnv;

beforeEach(() => {
delete require.cache[require.resolve("../src/config")];
originalEnv = { ...process.env };
});

afterEach(() => {
process.env = originalEnv;
});

describe("MODEL_PROVIDER Validation", () => {
it("should throw error for invalid provider", () => {
process.env.MODEL_PROVIDER = "openaix";

assert.throws(
() => require("../src/config"),
/Unsupported MODEL_PROVIDER.*openaix.*Valid options are:/
);
});

it("should throw error for completely invalid provider", () => {
process.env.MODEL_PROVIDER = "invalid";

assert.throws(
() => require("../src/config"),
/Unsupported MODEL_PROVIDER.*invalid.*Valid options are:/
);
});

it("should throw error for typo 'ollamma'", () => {
process.env.MODEL_PROVIDER = "ollamma";

assert.throws(
() => require("../src/config"),
/Unsupported MODEL_PROVIDER.*ollamma.*Valid options are:/
);
});

it("should throw error for partial name 'azure'", () => {
process.env.MODEL_PROVIDER = "azure";

assert.throws(
() => require("../src/config"),
/Unsupported MODEL_PROVIDER.*azure.*Valid options are:/
);
});

it("should list all valid providers in error message", () => {
process.env.MODEL_PROVIDER = "invalid";

try {
require("../src/config");
assert.fail("Should have thrown an error");
} catch (err) {
const message = err.message;
// Check that all valid providers are listed
assert.match(message, /azure-anthropic/);
assert.match(message, /azure-openai/);
assert.match(message, /bedrock/);
assert.match(message, /databricks/);
assert.match(message, /llamacpp/);
assert.match(message, /lmstudio/);
assert.match(message, /ollama/);
assert.match(message, /openai/);
assert.match(message, /openrouter/);
}
});

it("should accept valid provider 'ollama'", () => {
process.env.MODEL_PROVIDER = "ollama";
process.env.OLLAMA_ENDPOINT = "http://localhost:11434";
process.env.FALLBACK_ENABLED = "false";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "ollama");
});

it("should accept valid provider 'databricks'", () => {
process.env.MODEL_PROVIDER = "databricks";
process.env.DATABRICKS_API_BASE = "http://test.com";
process.env.DATABRICKS_API_KEY = "test-key";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "databricks");
});

it("should accept valid provider 'azure-anthropic'", () => {
process.env.MODEL_PROVIDER = "azure-anthropic";
process.env.AZURE_ANTHROPIC_ENDPOINT = "http://test.com";
process.env.AZURE_ANTHROPIC_API_KEY = "test-key";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "azure-anthropic");
});

it("should accept valid provider 'azure-openai'", () => {
process.env.MODEL_PROVIDER = "azure-openai";
process.env.AZURE_OPENAI_ENDPOINT = "https://test-resource.openai.azure.com";
process.env.AZURE_OPENAI_API_KEY = "test-key";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "azure-openai");
});

it("should accept valid provider 'openai'", () => {
process.env.MODEL_PROVIDER = "openai";
process.env.OPENAI_API_KEY = "test-key";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "openai");
});

it("should accept valid provider 'openrouter'", () => {
process.env.MODEL_PROVIDER = "openrouter";
process.env.OPENROUTER_API_KEY = "test-key";
process.env.DATABRICKS_API_BASE = "http://test.com";
process.env.DATABRICKS_API_KEY = "test-key";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "openrouter");
});

it("should accept valid provider 'llamacpp'", () => {
process.env.MODEL_PROVIDER = "llamacpp";
process.env.LLAMACPP_ENDPOINT = "http://localhost:8080";
process.env.DATABRICKS_API_BASE = "http://test.com";
process.env.DATABRICKS_API_KEY = "test-key";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "llamacpp");
});

it("should accept valid provider 'lmstudio'", () => {
process.env.MODEL_PROVIDER = "lmstudio";
process.env.LMSTUDIO_ENDPOINT = "http://localhost:1234";
process.env.DATABRICKS_API_BASE = "http://test.com";
process.env.DATABRICKS_API_KEY = "test-key";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "lmstudio");
});

it("should accept valid provider 'bedrock'", () => {
process.env.MODEL_PROVIDER = "bedrock";
process.env.AWS_BEDROCK_API_KEY = "test-key";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "bedrock");
});

it("should accept uppercase provider name 'OLLAMA'", () => {
process.env.MODEL_PROVIDER = "OLLAMA";
process.env.OLLAMA_ENDPOINT = "http://localhost:11434";
process.env.FALLBACK_ENABLED = "false";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "ollama");
});

it("should accept mixed-case provider name 'Databricks'", () => {
process.env.MODEL_PROVIDER = "Databricks";
process.env.DATABRICKS_API_BASE = "http://test.com";
process.env.DATABRICKS_API_KEY = "test-key";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "databricks");
});

it("should default to 'databricks' when MODEL_PROVIDER not set", () => {
delete process.env.MODEL_PROVIDER;
process.env.DATABRICKS_API_BASE = "http://test.com";
process.env.DATABRICKS_API_KEY = "test-key";

const config = require("../src/config");

assert.strictEqual(config.modelProvider.type, "databricks");
});

it("should show original case in error message for case-sensitive debugging", () => {
process.env.MODEL_PROVIDER = "OpEnAIx";

try {
require("../src/config");
assert.fail("Should have thrown an error");
} catch (err) {
// Error message should show original case for debugging
assert.match(err.message, /OpEnAIx/);
}
});
});
});
2 changes: 1 addition & 1 deletion test/hybrid-routing-integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe("Hybrid Routing Integration Tests", () => {

assert.throws(() => {
require("../src/config");
}, /FALLBACK_PROVIDER must be one of/);
}, /Unsupported FALLBACK_PROVIDER.*Valid options are/);
});

it("should reject circular fallback (ollama -> ollama)", () => {
Expand Down
Loading