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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@
"script5": "tsx -r dotenv/config ./src/scripts/cli5.ts --name 'Jo' --location 'New York, NY'",
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
"test:live:handoffs": "RUN_HANDOFF_LIVE_TESTS=1 NODE_OPTIONS='--experimental-vm-modules' jest src/specs/agent-handoffs.live.test.ts --runInBand",
"test:live:cross-provider-attachments": "RUN_CROSS_PROVIDER_ATTACHMENT_LIVE_TESTS=1 NODE_OPTIONS='--experimental-vm-modules' jest src/specs/cross-provider-attachments.live.test.ts --runInBand",
"test:live:ask-user-questions": "RUN_ASK_USER_QUESTIONS_LIVE_TESTS=1 NODE_OPTIONS='--experimental-vm-modules' jest src/specs/ask-user-questions.live.test.ts --runInBand",
"test:memory": "NODE_OPTIONS='--expose-gc' npx jest src/specs/title.memory-leak.test.ts",
"test:all": "npm test -- --testPathIgnorePatterns=title.memory-leak.test.ts && npm run test:memory",
Expand Down
9 changes: 8 additions & 1 deletion src/llm/prepareProviderRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('prepareProviderRequest', () => {
['CSV', 'csv'],
['XLSX', 'xlsx'],
])(
'removes a persisted Bedrock %s block for OpenAI while retaining extracted file context',
'recovers an existing Bedrock %s chat after its agent moves to an OpenAI-compatible endpoint',
(_label, format) => {
const { model } = createCapturingModel();
const document = {
Expand All @@ -65,12 +65,19 @@ describe('prepareProviderRequest', () => {
],
});

const bedrockRequest = prepareProviderRequest({
model: model as t.ChatModel,
messages: [source],
provider: Providers.BEDROCK,
});
const request = prepareProviderRequest({
model: model as t.ChatModel,
messages: [source],
provider: Providers.OPENAI,
});

expect(bedrockRequest.messages[0]).toBe(source);
expect(bedrockRequest.messages[0].content[1]).toBe(document);
expect(request.messages[0]).not.toBe(source);
expect(request.messages[0].content).toEqual([
{ type: 'text', text: 'Attached document(s):\ncol1\nvalue' },
Expand Down
173 changes: 173 additions & 0 deletions src/specs/cross-provider-attachments.live.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* Live recovery verification for chats created before an agent moved from
* Bedrock to an OpenAI-compatible endpoint.
*
* Run with:
* CROSS_PROVIDER_ATTACHMENT_BASE_URL=... \
* CROSS_PROVIDER_ATTACHMENT_API_KEY=... \
* CROSS_PROVIDER_ATTACHMENT_MODEL=... \
* npm run test:live:cross-provider-attachments
*/
import { config as dotenvConfig } from 'dotenv';
dotenvConfig({ path: process.env.DOTENV_CONFIG_PATH ?? '.env' });

import { HumanMessage } from '@langchain/core/messages';
import { describe, expect, it, jest } from '@jest/globals';
import type { ContentBlock } from '@langchain/core/messages';
import type * as t from '@/types';
import { Providers } from '@/common';
import { Run } from '@/run';

const apiKey = process.env.CROSS_PROVIDER_ATTACHMENT_API_KEY;
const configuredBaseURL = process.env.CROSS_PROVIDER_ATTACHMENT_BASE_URL;
const model = process.env.CROSS_PROVIDER_ATTACHMENT_MODEL;
const shouldRunLive =
process.env.RUN_CROSS_PROVIDER_ATTACHMENT_LIVE_TESTS === '1' &&
apiKey != null &&
apiKey !== '' &&
configuredBaseURL != null &&
configuredBaseURL !== '' &&
model != null &&
model !== '';
const describeIfLive = shouldRunLive ? describe : describe.skip;
const expectedAnswer = '42';

interface PersistedBedrockDocument extends ContentBlock {
type: 'document';
document: {
name: string;
format: 'csv' | 'xlsx';
source: {
bytes: { type: 'Buffer'; data: number[] };
};
};
}

function requireLiveValue(
value: string | undefined,
name: string
): string {
if (value == null || value === '') {
throw new Error(`${name} is required`);
}
return value;
}

function normalizeBaseURL(value: string): string {
return value
.replace(/\/chat\/completions\/?$/, '')
.replace(/\/$/, '');
}

function createPersistedDocument(
name: string,
format: PersistedBedrockDocument['document']['format']
): PersistedBedrockDocument {
return {
type: 'document',
document: {
name,
format,
source: {
bytes: { type: 'Buffer', data: [80, 75, 3, 4] },
},
},
};
}

function contentPartsToText(
content: t.MessageContentComplex[] | undefined
): string {
const text: string[] = [];
for (const block of content ?? []) {
if (block.type === 'text' && typeof block.text === 'string') {
text.push(block.text);
}
}
return text.join('');
}

describeIfLive('cross-provider attachment recovery live API', () => {
jest.setTimeout(120_000);

it('reads extracted CSV/XLSX context without sending persisted Bedrock documents', async () => {
const requestBodies: string[] = [];
const nativeFetch = globalThis.fetch.bind(globalThis);
const capturingFetch: typeof fetch = async (input, init) => {
if (typeof init?.body === 'string') {
requestBodies.push(init.body);
}
return nativeFetch(input, init);
};
const resolvedApiKey = requireLiveValue(
apiKey,
'CROSS_PROVIDER_ATTACHMENT_API_KEY'
);
const resolvedModel = requireLiveValue(
model,
'CROSS_PROVIDER_ATTACHMENT_MODEL'
);
const baseURL = normalizeBaseURL(
requireLiveValue(
configuredBaseURL,
'CROSS_PROVIDER_ATTACHMENT_BASE_URL'
)
);
const nonce = `cross-provider-attachment-${Date.now()}`;
const run = await Run.create({
runId: nonce,
graphConfig: {
type: 'standard',
llmConfig: {
provider: Providers.OPENAI,
model: resolvedModel,
modelName: resolvedModel,
apiKey: resolvedApiKey,
temperature: 0,
maxTokens: 32,
streaming: false,
streamUsage: false,
configuration: { baseURL, fetch: capturingFetch },
},
instructions:
'Answer only with the requested numeric total. Do not add punctuation or explanation.',
},
returnContent: true,
skipCleanup: true,
});
const historicalMessage = new HumanMessage({
content: [
{
type: 'text',
text: [
'Attached document(s):',
'sales.csv extracted rows: north revenue = 17',
'forecast.xlsx extracted rows: north forecast = 25',
`Recovery probe: ${nonce}`,
'Return the sum of north revenue and north forecast.',
].join('\n'),
},
createPersistedDocument('sales.csv', 'csv'),
createPersistedDocument('forecast.xlsx', 'xlsx'),
],
});

const finalContent = await run.processStream(
{ messages: [historicalMessage] },
{
configurable: { thread_id: `${nonce}-thread` },
version: 'v2',
}
);

const requestBody = requestBodies.at(-1);
expect(requestBody).toBeDefined();
expect(requestBody).not.toContain('"type":"document"');
expect(requestBody).toContain('sales.csv extracted rows');
expect(requestBody).toContain('forecast.xlsx extracted rows');
expect(historicalMessage.content).toHaveLength(3);

const finalText = contentPartsToText(finalContent).trim();
expect(finalText).toBe(expectedAnswer);
});
});
43 changes: 43 additions & 0 deletions src/tools/search/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,46 @@ describe('formatResultsForLLM highlight budget', () => {
expect(output).toContain('_[1 additional highlight omitted to fit the context budget');
});
});

describe('formatResultsForLLM on a failed search', () => {
test.each([null, undefined])('handles %p result data', (results) => {
expect(formatResultsForLLM(0, results).output).toBe(
'Search failed: Search provider returned no result data'
);
});

test('tells the model the search failed instead of returning an empty output', () => {
const results: t.SearchResultData = {
organic: [],
topStories: [],
images: [],
videos: [],
news: [],
relatedSearches: [],
error: 'search provider request failed',
};

const { output, references } = formatResultsForLLM(0, results, 50000);

expect(output).toBe('Search failed: search provider request failed');
expect(references).toEqual([]);
});

test('keeps any partial results after the failure notice', () => {
const results: t.SearchResultData = {
organic: [makeOrganic('https://a.com', [highlight('A')])],
error: 'news lookup failed',
};

const { output } = formatResultsForLLM(0, results, 50000);

expect(output).toMatch(/^Search failed: news lookup failed/);
expect(output).toContain('=== Web Results, Turn 0 ===');
});

test('ignores an empty-string error', () => {
const results: t.SearchResultData = { organic: [], error: '' };

expect(formatResultsForLLM(0, results, 50000).output).toBe('');
});
});
16 changes: 15 additions & 1 deletion src/tools/search/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const DEFAULT_MAX_LLM_OUTPUT_CHARS = 50000;
* this we drop it whole rather than emit a useless sliver. */
const MIN_PARTIAL_HIGHLIGHT_CHARS = 200;

export const MISSING_SEARCH_RESULT_DATA_ERROR =
'Search provider returned no result data';

/** Resolves the per-search highlight budget from config, the
* `SEARCH_MAX_LLM_OUTPUT_CHARS` env var, or the default (50,000 chars). */
export function resolveMaxLLMOutputChars(maxOutputChars?: number): number {
Expand Down Expand Up @@ -219,9 +222,16 @@ function formatSource(

export function formatResultsForLLM(
turn: number,
results: t.SearchResultData,
results?: t.SearchResultData | null,
maxOutputChars?: number
): { output: string; references: t.ResultReference[] } {
if (results == null) {
return {
output: `Search failed: ${MISSING_SEARCH_RESULT_DATA_ERROR}`,
references: [],
};
}

/** Bound highlight content to the per-search budget before formatting */
const trimmedHighlights = trimHighlightsToBudget(
results,
Expand All @@ -239,6 +249,10 @@ export function formatResultsForLLM(

const references: t.ResultReference[] = [];

if (results.error != null && results.error !== '') {
outputLines.push(`Search failed: ${results.error}`);
}

// Organic (web) results
if (results.organic?.length != null && results.organic.length > 0) {
addSection(`Web Results, Turn ${turn}`);
Expand Down
8 changes: 7 additions & 1 deletion src/tools/search/outcome.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { describe, it, expect } from '@jest/globals';
import type * as t from './types';
import { resolveSearchOutcome } from './tool';
import { normalizeSearchResultData, resolveSearchOutcome } from './tool';

const data = (partial: Partial<t.SearchResultData>): t.SearchResultData =>
({ turn: 0, ...partial }) as t.SearchResultData;

describe('resolveSearchOutcome', () => {
test.each([null, undefined])('normalizes %p result data as a failure', (result) => {
expect(resolveSearchOutcome(normalizeSearchResultData(result), 'oauth')).toBe(
'Search failed for "oauth"'
);
});

it('authors a FAILURE label when the processor caught an error', () => {
expect(
resolveSearchOutcome(
Expand Down
16 changes: 13 additions & 3 deletions src/tools/search/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import { INTENT_PROPERTY } from '@/tools/intentArg';
import { createCrwScraper } from './crw-scraper';
import { expandHighlights } from './highlights';
import { createSearchMetrics } from './metrics';
import { formatResultsForLLM } from './format';
import {
formatResultsForLLM,
MISSING_SEARCH_RESULT_DATA_ERROR,
} from './format';
import { createDefaultLogger } from './utils';
import { createReranker } from './rerankers';
import { Constants } from '@/common';
Expand Down Expand Up @@ -66,6 +69,12 @@ export function resolveSearchOutcome(
return `Found ${count} result${count === 1 ? '' : 's'} for "${query}"`;
}

export function normalizeSearchResultData(
result: t.SearchResultData | null | undefined
): t.SearchResultData {
return result ?? { error: MISSING_SEARCH_RESULT_DATA_ERROR };
}

/** Distinct rows across the main search's two collections. SearXNG derives
* both from one result array — a row matching its news heuristic lands in
* `organic` and `topStories` alike — so summing the lengths would report
Expand Down Expand Up @@ -431,12 +440,13 @@ function createTool({
}),
});
const turn = runnableConfig.toolCall?.turn ?? 0;
const resultData = normalizeSearchResultData(searchResult);
const { output, references } = formatResultsForLLM(
turn,
searchResult,
resultData,
maxOutputChars
);
const data: t.SearchResultData = { turn, ...searchResult, references };
const data: t.SearchResultData = { turn, ...resultData, references };
const outcome = resolveSearchOutcome(data, query);
return [
output,
Expand Down
Loading