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
6 changes: 6 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ jobs:
- name: Check circular dependencies
run: npm run check:circular-deps

- name: Build declarations
run: npx tsc -p tsconfig.build.json

- name: Test light entry points
run: npm run test:light-entries

unit-tests:
name: 'unit tests (shard ${{ matrix.shard }}/4)'
needs: install
Expand Down
158 changes: 158 additions & 0 deletions config/light-entries.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import path from 'node:path';
import { promisify } from 'node:util';
import { readFile } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire, isBuiltin, registerHooks } from 'node:module';

const root = path.resolve(fileURLToPath(import.meta.url), '../..');
const packageName = '@librechat/agents';
const run = promisify(execFile);

/**
* Subpaths that must load without any third-party package, so a host such as
* a code-intelligence daemon can use typed decisions and reranking without
* pulling LangChain or provider SDKs into its process.
*/
export const lightSubpaths = Object.freeze(['./decisions', './rerank']);

/**
* Load one built package subpath through its own exports map in a fresh Node
* process, recording every module resolution. `packages` lists bare
* specifiers that are neither Node built-ins nor the entry itself.
*/
export async function probe(subpath, format) {
const { stdout } = await run(
process.execPath,
[fileURLToPath(import.meta.url), '--probe', subpath, format],
{ cwd: root, maxBuffer: 16 * 1024 * 1024 }
);
return JSON.parse(stdout);
}

function record(subpath) {
const entry = `${packageName}${subpath.slice(1)}`;
const modules = new Set();
const packages = new Set();
registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context);
if (result.url.startsWith('file:')) {
modules.add(path.relative(root, fileURLToPath(result.url)));
}
const relative = specifier.startsWith('.') || specifier.startsWith('/');
const external =
!relative &&
!specifier.startsWith('file:') &&
!isBuiltin(specifier) &&
specifier !== entry;
if (external) {
packages.add(specifier);
}
return result;
},
});
return { entry, modules, packages };
}

async function runProbe(subpath, format) {
const { entry, modules, packages } = record(subpath);
if (format === 'cjs') {
createRequire(path.join(root, 'package.json'))(entry);
} else {
await import(entry);
}
process.stdout.write(
JSON.stringify({
entry,
format,
modules: [...modules].sort(),
packages: [...packages].sort(),
})
);
}

const declarationSpecifier =
/(?:\bfrom\s+|\bimport\s*\(\s*|^\s*import\s+)['"]([^'"]+)['"]/gm;

async function resolveDeclaration(fromFile, specifier) {
const base = path.resolve(path.dirname(fromFile), specifier);
const candidates = [
base.replace(/\.(?:m|c)?js$/, '.d.ts'),
`${base}.d.ts`,
path.join(base, 'index.d.ts'),
];
for (const candidate of candidates) {
try {
await readFile(candidate);
return candidate;
} catch {
continue;
}
}
return null;
}

/**
* Walk the declaration graph from a subpath's `types` file. `external` lists
* every non-relative specifier, including unresolved `@/` path aliases that
* consumers cannot resolve; `missing` lists relative imports with no file.
*/
export async function walkDeclarations(subpath) {
const manifest = JSON.parse(
await readFile(path.join(root, 'package.json'), 'utf8')
);
const start = path.join(root, manifest.exports[subpath].types);
const files = new Set();
const external = new Set();
const missing = new Set();
const queue = [start];
while (queue.length > 0) {
const file = queue.pop();
if (files.has(file)) {
continue;
}
files.add(file);
const source = await readFile(file, 'utf8');
for (const [, specifier] of source.matchAll(declarationSpecifier)) {
if (!specifier.startsWith('.')) {
if (!isBuiltin(specifier)) {
external.add(specifier);
}
continue;
}
const resolved = await resolveDeclaration(file, specifier);
if (resolved === null) {
missing.add(`${path.relative(root, file)} -> ${specifier}`);
continue;
}
queue.push(resolved);
}
}
return {
files: [...files].map((file) => path.relative(root, file)).sort(),
external: [...external].sort(),
missing: [...missing].sort(),
};
}

const invokedDirectly =
process.argv[1] !== undefined &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;

if (invokedDirectly && process.argv[2] === '--probe') {
await runProbe(process.argv[3], process.argv[4]);
} else if (invokedDirectly) {
for (const subpath of lightSubpaths) {
for (const format of ['esm', 'cjs']) {
const result = await probe(subpath, format);
console.log(
`${result.entry} (${format}): ${result.modules.length} modules, ${result.packages.length} packages${result.packages.length > 0 ? `: ${result.packages.join(', ')}` : ''}`
);
}
const types = await walkDeclarations(subpath);
console.log(
`${packageName}${subpath.slice(1)} (types): ${types.files.length} declaration files, ${types.external.length} external specifiers`
);
}
}
55 changes: 55 additions & 0 deletions config/light-entries.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import path from 'node:path';
import test from 'node:test';
import { access } from 'node:fs/promises';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';

import { lightSubpaths, probe, walkDeclarations } from './light-entries.mjs';
import { packageEntries } from './package-entries.mjs';
import manifest from '../package.json' with { type: 'json' };

const root = path.resolve(fileURLToPath(import.meta.url), '../..');

test('light subpaths are built entries with esm, cjs and declaration outputs', async () => {
for (const subpath of lightSubpaths) {
const target = manifest.exports[subpath];
assert.ok(target, `package.json exports ${subpath}`);
const entry = target.import.replace(/^\.\/dist\/esm\/|\.mjs$/g, '');
assert.equal(packageEntries[entry], `src/${entry}.ts`);
assert.equal(target.require, `./dist/cjs/${entry}.cjs`);
assert.equal(target.types, `./dist/types/${entry}.d.ts`);
assert.deepEqual(manifest.typesVersions['*'][subpath.slice(2)], [
target.types.slice(2),
]);
for (const file of [target.types, target.import, target.require]) {
await access(path.join(root, file));
}
}
});

for (const subpath of lightSubpaths) {
for (const format of ['esm', 'cjs']) {
test(`${subpath} loads without third-party packages (${format})`, async (t) => {
const result = await probe(subpath, format);
t.diagnostic(`${result.modules.length} modules`);
assert.deepEqual(result.packages, []);
assert.ok(result.modules.length > 0);
assert.ok(
result.modules.every((file) => file.startsWith(`dist/${format}/`)),
result.modules.join('\n')
);
});
}

test(`${subpath} declarations resolve inside the package`, async (t) => {
const result = await walkDeclarations(subpath);
t.diagnostic(`${result.files.length} declaration files`);
assert.deepEqual(result.external, []);
assert.deepEqual(result.missing, []);
});
}

test('the probe reports third-party packages a subpath reaches', async () => {
const result = await probe('./langchain/messages', 'esm');
assert.ok(result.packages.includes('@langchain/core/messages'));
});
2 changes: 2 additions & 0 deletions config/package-entries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const packageEntries = Object.freeze({
main: 'src/index.ts',
'provider-registration': 'src/provider-registration.ts',
'openai/index': 'src/openai/index.ts',
'decisions/core': 'src/decisions/core.ts',
'rerank/core': 'src/rerank/core.ts',
'llm/providers.eager': 'src/llm/providers.eager.ts',
'llm/openai/index': 'src/llm/openai/index.ts',
'llm/mistral/index': 'src/llm/mistral/index.ts',
Expand Down
17 changes: 17 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
"import": "./dist/esm/provider-registration.mjs",
"require": "./dist/cjs/provider-registration.cjs"
},
"./decisions": {
"types": "./dist/types/decisions/core.d.ts",
"import": "./dist/esm/decisions/core.mjs",
"require": "./dist/cjs/decisions/core.cjs"
},
"./rerank": {
"types": "./dist/types/rerank/core.d.ts",
"import": "./dist/esm/rerank/core.mjs",
"require": "./dist/cjs/rerank/core.cjs"
},
"./openai": {
"types": "./dist/types/openai/index.d.ts",
"import": "./dist/esm/openai/index.mjs",
Expand Down Expand Up @@ -120,6 +130,12 @@
"provider-registration": [
"dist/types/provider-registration.d.ts"
],
"decisions": [
"dist/types/decisions/core.d.ts"
],
"rerank": [
"dist/types/rerank/core.d.ts"
],
"langchain": [
"dist/types/langchain/index.d.ts"
],
Expand Down Expand Up @@ -193,6 +209,7 @@
"build:dev": "tsdown",
"check:circular-deps": "node config/circular-deps.mjs",
"test:circular-deps": "node --test config/circular-deps.test.mjs",
"test:light-entries": "node --test config/light-entries.test.mjs",
"sort-imports": "node scripts/sort-imports.ts",
"sort-imports:check": "node scripts/sort-imports.ts --check",
"start": "node dist/esm/main.js",
Expand Down
4 changes: 4 additions & 0 deletions src/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ Use the request's `signal` and `timeoutMs` to bound a call. HTTP timeouts cover

The structured-chat adapter sends the original state to the provider. Its prompt carries a marker so Langfuse can drop the **entire state and question text** from generation inputs when any tool-output redaction policy is active. Selectively removing identifiable tool fields would still leak a private result quoted in free-form state or instructions. With no active redaction policy, prompts remain visible. Configure provider-side logging separately. No defaults should change until quality, calibration, latency, and cost have been evaluated for each backend and use case.

## Light Entry Point

`@librechat/agents/decisions` exports the System One HTTP stack (types, question builders, presets, `createHttpDecisionModel`, the dialect parser, `createTransport`, `failureForStatus`, `retryAfterMs`, and `withDecisionDeadline`) without loading LangChain or any other third-party package. `createStructuredChatDecisionModel` binds a LangChain chat model, so it is exported only from the root entry. The root `@librechat/agents` entry still exports everything. `npm run test:light-entries` loads the built subpath and fails if it reaches a non-built-in package.

## Structured Output Capabilities

The private `structuredOutput.ts` binding boundary owns adapter/method options. The caller explicitly chooses the method; there is no automatic mode fallback, public capability registry, provider-profile override, or extra provider SDK import at runtime.
Expand Down
20 changes: 20 additions & 0 deletions src/decisions/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Light `@librechat/agents/decisions` entry: the System One HTTP decision
* stack with no LangChain or other third-party runtime import. The
* structured-chat adapter stays on the root entry because it binds a
* LangChain chat model.
*/
export * from './types';
export * from './questions';
export * from './presets';
export {
createHttpDecisionModel,
parseEnvelope,
HTTP_PROVIDER_ID,
} from './http';
export type { HttpDecisionModelOptions } from './http';
export { toWireQuestion, readAnswer } from './dialect';
export { createTransport, failureForStatus, retryAfterMs } from './transport';
export type { DecisionFetch, Transport, TransportOptions } from './transport';
export { withDecisionDeadline } from './deadline';
export type { AwaitWithinDeadline } from './deadline';
13 changes: 1 addition & 12 deletions src/decisions/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,3 @@
export * from './types';
export * from './questions';
export * from './presets';
export {
createHttpDecisionModel,
parseEnvelope,
HTTP_PROVIDER_ID,
} from './http';
export * from './core';
export { createStructuredChatDecisionModel } from './structuredChat';
export type { HttpDecisionModelOptions } from './http';
export type { StructuredChatDecisionModelOptions } from './structuredChat';
export { toWireQuestion, readAnswer } from './dialect';
export { createTransport } from './transport';
export type { DecisionFetch, Transport, TransportOptions } from './transport';
4 changes: 4 additions & 0 deletions src/rerank/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

`Reranker.rerank({ query, documents, topK?, signal?, timeoutMs? })` returns ranked input indices and provider-specific scores. Indices preserve candidate identity even when two documents have identical text. A successful ranking is ordered by descending score. Scores are not portable confidence thresholds. Missing usage is `null`.

## Light entry point

`@librechat/agents/rerank` exports the `Reranker` types and `createSystemOneReranker` without loading LangChain or any other third-party package; pair it with `@librechat/agents/decisions` to build the decision model. `createWebSearchReranker` extends the search tool's reranker, so it is exported only from the root entry.

## Host-owned web-search ranking

`createSearchTool({ reranker })` now honors an injected `SearchReranker` before constructing its configured built-in reranker. Hosts can supply a plain `{ provider, rerank(query, documents, topK?, metrics?) }` object. Extending `BaseReranker` is not required. Existing Jina, Cohere, rag_api, Infinity, and `none` selection stays unchanged when there is no injection.
Expand Down
9 changes: 9 additions & 0 deletions src/rerank/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Light `@librechat/agents/rerank` entry: the index-based reranking contract
* and the System One adapter, with no LangChain or other third-party runtime
* import. The web-search adapter stays on the root entry because it extends
* the search tool's reranker.
*/
export type * from './types';
export { createSystemOneReranker } from './systemone';
export type { SystemOneRerankerOptions } from './systemone';
4 changes: 1 addition & 3 deletions src/rerank/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
export type * from './types';
export { createSystemOneReranker } from './systemone';
export type { SystemOneRerankerOptions } from './systemone';
export * from './core';
export { createWebSearchReranker } from './search';
15 changes: 8 additions & 7 deletions src/rerank/systemone.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import type {
DecisionModel,
DecisionQuestion,
DecisionResult,
} from '../decisions/types';
import type {
Reranker,
RerankRequest,
RerankResult,
RankedDocument,
} from './types';
import type {
DecisionModel,
DecisionQuestion,
DecisionResult,
} from '@/decisions';
import { DecisionError, scoreQuestion, isDecisionObject } from '@/decisions';
import { withDecisionDeadline } from '@/decisions/deadline';
import { DecisionError, isDecisionObject } from '../decisions/types';
import { withDecisionDeadline } from '../decisions/deadline';
import { scoreQuestion } from '../decisions/questions';

export interface SystemOneRerankerOptions {
/** Inject a configured Jev/Laya HTTP decision model; chat cannot produce expected scores. */
Expand Down
2 changes: 1 addition & 1 deletion src/rerank/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DecisionUsage } from '@/decisions';
import type { DecisionUsage } from '../decisions/types';

export interface RerankRequest {
query: string;
Expand Down
Loading