mirror of
https://github.com/SHOGGOTH-SECTOR/sica-fondt.git
synced 2026-08-01 08:30:20 +00:00
Place useful parts of the surrounding repos into sica-fondt by layer, per the
body model (Ada = membrane; brain/endocrine/capabilities/knowledge non-Ada):
- brain/ LLM reasoning + providers (dapr, hermes, MoMoA)
- capabilities/ REPRAG sidecars: hermes tools/skills, dapr tools, parallel
dispatch, A51 channels, and the OSINT cluster
- knowledge/ LORAG corpus: 754 cyber-skills, agency personas, secure-coding,
MITRE ATT&CK data
- reference/ defensive threat-reference (C3, shhbruh doc) + AdaYaml parser
License handling: AGPL sources (worldosint, advanced_evolution, mercury,
Reticulum) and GPL DeTTECT are SPEC-only clean-room/port descriptions — no
copyleft code copied. MIT/Apache/data parts copied as working trees.
Safety: shhbruh escape/persistence material and C3 covert-C2 kept as reference
only, not wired into the running organism. See CONSOLIDATION.md.
https://claude.ai/code/session_01UehUqEXXJJCsHoA4voCU5c
823 lines
29 KiB
TypeScript
823 lines
29 KiB
TypeScript
/**
|
|
* Copyright 2026 Reto Meier
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import {
|
|
GenerateContentResponse,
|
|
Content,
|
|
GenerateContentConfig,
|
|
Part,
|
|
FinishReason,
|
|
BlockedReason,
|
|
Tool,
|
|
Type,
|
|
} from '@google/genai';
|
|
import { ApiPolicyManager } from './apiPolicyManager.js';
|
|
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
|
import { TranscriptManager } from './transcriptManager.js';
|
|
import { ContentGenerator, createContentGenerator, ContentGeneratorConfig, AuthType, resolveApiKeyForModel } from './contentGenerator.js';
|
|
import { FormattedTranscriptEntry, GeminiClientConfig, MarkerPair, ToolFunctionDeclaration } from '../momoa_core/types.js';
|
|
import { LlmBlockedError } from '../shared/errors.js';
|
|
|
|
const MAX_ATTEMPTS = 6;
|
|
|
|
/**
|
|
* Interface for options passed to content generation methods.
|
|
*/
|
|
export interface GenerateContentOptions {
|
|
model?: string;
|
|
temperature?: number;
|
|
enableThinking?: boolean;
|
|
enableGrounding?: boolean;
|
|
signal?: AbortSignal;
|
|
responseMimeType?: string;
|
|
tools?: ToolFunctionDeclaration[];
|
|
}
|
|
|
|
/**
|
|
* Client for interacting with the Gemini API in a multi-agent context.
|
|
* Manages API calls, applies policy (rate limiting, backoff),
|
|
* and integrates with chat history and telemetry.
|
|
*/
|
|
export class GeminiClient {
|
|
private contentGenerators = new Map<string, Promise<ContentGenerator>>();
|
|
private readonly apiPolicyManager: ApiPolicyManager;
|
|
private readonly config: GeminiClientConfig;
|
|
private readonly apiName = 'Gemini';
|
|
|
|
// We store non-cached input, cached input, and output tokens separately.
|
|
private tokenUsage = new Map<string, {
|
|
inputTokens: number,
|
|
outputTokens: number,
|
|
cachedInputTokens: number
|
|
}>();
|
|
|
|
constructor(config: GeminiClientConfig, apiPolicyManager: ApiPolicyManager) {
|
|
this.apiPolicyManager = apiPolicyManager;
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Private method to retrieve or create a ContentGenerator instance for a given model.
|
|
* The instance is keyed by the resolved API key to ensure that clients using the same key
|
|
* (even if for different models) share the same underlying generator, and clients using
|
|
* different keys get separate generators.
|
|
*/
|
|
private async getContentGenerator(model: string): Promise<ContentGenerator> {
|
|
const apiKey = resolveApiKeyForModel(model, this.config.apiKey);
|
|
|
|
if (!apiKey) {
|
|
throw new Error(`API Key not found for model: ${model}. Check environment variables or default configuration.`);
|
|
}
|
|
|
|
if (this.contentGenerators.has(apiKey)) {
|
|
return this.contentGenerators.get(apiKey)!;
|
|
}
|
|
|
|
console.log(`Creating new Content Generator for API Key ${apiKey} (for ${model}).`)
|
|
|
|
const generatorPromise = (async () => {
|
|
try {
|
|
const contentGeneratorConfig: ContentGeneratorConfig = {
|
|
model: model, // Use the requested model for initial config
|
|
authType: AuthType.USE_GEMINI,
|
|
apiKey: apiKey,
|
|
};
|
|
|
|
const contentGenerator = await createContentGenerator(
|
|
contentGeneratorConfig,
|
|
);
|
|
return contentGenerator;
|
|
} catch (error) {
|
|
console.error(
|
|
`FATAL: GeminiClient failed to initialize ContentGenerator for model ${model}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
// Remove the failed promise from the cache to allow retries if needed
|
|
this.contentGenerators.delete(apiKey);
|
|
throw new Error('ContentGenerator initialization failed.');
|
|
}
|
|
})();
|
|
|
|
this.contentGenerators.set(apiKey, generatorPromise);
|
|
return generatorPromise;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the aggregated token usage statistics.
|
|
* @returns A Map where the key is the model name and the value is an
|
|
* object containing total input, output, and cached-input tokens.
|
|
*/
|
|
public getTokenUsage(): Map<string, {
|
|
inputTokens: number,
|
|
outputTokens: number,
|
|
cachedInputTokens: number
|
|
}> {
|
|
return this.tokenUsage;
|
|
}
|
|
|
|
/**
|
|
* Updates the token usage statistics based on an API response.
|
|
* This now correctly subtracts cached tokens from the prompt tokens.
|
|
* @param modelName The name of the model that was used.
|
|
* @param response The GenerateContentResponse from the API.
|
|
*/
|
|
private updateTokenUsage(modelName: string, response: GenerateContentResponse): void {
|
|
const usage = response.usageMetadata;
|
|
if (usage) {
|
|
// Get the current stats, or initialize them
|
|
const stats = this.tokenUsage.get(modelName) || {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cachedInputTokens: 0
|
|
};
|
|
|
|
// Get all token counts, defaulting to 0
|
|
const promptTokens = usage.promptTokenCount || 0;
|
|
const cachedTokens = usage.cachedContentTokenCount || 0;
|
|
const outputTokens = usage.candidatesTokenCount || 0;
|
|
|
|
// Calculate the non-cached (billable) input tokens
|
|
const nonCachedInputTokens = promptTokens - cachedTokens;
|
|
|
|
// Update the totals
|
|
stats.inputTokens += nonCachedInputTokens;
|
|
stats.outputTokens += outputTokens;
|
|
stats.cachedInputTokens += cachedTokens;
|
|
|
|
this.tokenUsage.set(modelName, stats);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects and cleans repetitive output where a block of lines is repeated more than 5 times.
|
|
* Keeps the first two iterations and replaces the rest with a placeholder.
|
|
* Also removes a trailing line if it is a substring of the start of the block.
|
|
*/
|
|
private _removeRepetitiveBlocks(text: string): string {
|
|
const MAX_REPETITIONS = 10;
|
|
const lines = text.split('\n');
|
|
const cleanedLines: string[] = [];
|
|
let i = 0;
|
|
|
|
while (i < lines.length) {
|
|
let bestBlockSize = 0;
|
|
let bestRepeatCount = 0;
|
|
|
|
// We look for a block of size k that repeats at least 6 times (original + 5 repeats).
|
|
// Max feasible block size is remaining_lines / 6.
|
|
const maxBlockSize = Math.floor((lines.length - i) / 6);
|
|
|
|
// Check for repetitions starting at i with block size k
|
|
for (let k = 1; k <= maxBlockSize; k++) {
|
|
let count = 1; // The block exists at least once
|
|
let p = i + k; // Start of the next potential block
|
|
|
|
while (p + k <= lines.length) {
|
|
let match = true;
|
|
// Compare current block [p ... p+k-1] with the reference block [i ... i+k-1]
|
|
for (let j = 0; j < k; j++) {
|
|
if (lines[i + j] !== lines[p + j]) {
|
|
match = false;
|
|
break;
|
|
}
|
|
}
|
|
if (match) {
|
|
count++;
|
|
p += k;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (count > MAX_REPETITIONS) {
|
|
// Found a repetition > 5. Since we iterate k from 1, this finds the smallest repeating unit.
|
|
bestBlockSize = k;
|
|
bestRepeatCount = count;
|
|
break; // Stop searching for larger blocks, prioritize the smallest repeating unit.
|
|
}
|
|
}
|
|
|
|
if (bestBlockSize > 0) {
|
|
// Keep the allowable iterations of the block
|
|
for (let j = 0; j < MAX_REPETITIONS * bestBlockSize; j++) {
|
|
cleanedLines.push(lines[i + j]);
|
|
}
|
|
|
|
cleanedLines.push(`[--- ${bestRepeatCount - MAX_REPETITIONS}x duplicated blocks removed---]`);
|
|
|
|
// Advance index past all duplicates
|
|
i += bestBlockSize * bestRepeatCount;
|
|
|
|
if (i < lines.length) {
|
|
const nextLine = lines[i];
|
|
const firstLineOfBlock = lines[i - (bestBlockSize * bestRepeatCount)];
|
|
|
|
if (nextLine.length > 0 && firstLineOfBlock.startsWith(nextLine)) {
|
|
i++;
|
|
}
|
|
}
|
|
} else {
|
|
// No excessive duplication found at this line
|
|
cleanedLines.push(lines[i]);
|
|
i++;
|
|
}
|
|
}
|
|
|
|
return cleanedLines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Maps our internal FormattedTranscriptEntry[] structure to the Gemini SDK's Content[] structure.
|
|
* This is necessary because the SDK's Part type is structurally compatible with
|
|
* FormattedTranscriptPart, but TypeScript requires explicit mapping or casting
|
|
* due to different import sources.
|
|
*/
|
|
private _mapFormattedTranscriptToContent(
|
|
transcript: FormattedTranscriptEntry[]
|
|
): Content[] {
|
|
return transcript.map(entry => ({
|
|
role: entry.role,
|
|
// We assume FormattedTranscriptPart is structurally identical to the SDK's Part type.
|
|
// We must cast here to satisfy the Content type definition.
|
|
parts: entry.parts as Part[],
|
|
}));
|
|
}
|
|
|
|
private _createErrorResponse(
|
|
errorMessage: string,
|
|
): GenerateContentResponse {
|
|
console.error(`GeminiClient returning error response: ${errorMessage}`);
|
|
const errorText = `--- The Gemini API was unable to provide a response (${errorMessage}) ---`;
|
|
return {
|
|
candidates: [
|
|
{
|
|
content: {
|
|
role: 'model',
|
|
parts: [
|
|
{
|
|
text: errorText,
|
|
},
|
|
],
|
|
},
|
|
finishReason: FinishReason.FINISH_REASON_UNSPECIFIED,
|
|
index: 0,
|
|
safetyRatings: [],
|
|
},
|
|
],
|
|
promptFeedback: {
|
|
blockReason: BlockedReason.BLOCKED_REASON_UNSPECIFIED,
|
|
safetyRatings: [],
|
|
},
|
|
text: errorText,
|
|
data: '',
|
|
functionCalls: [],
|
|
executableCode: '',
|
|
codeExecutionResult: '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Tries to parse an API error message (which might be a JSON string)
|
|
* to find a 'retryDelay' field from a Google RPC QuotaFailure.
|
|
* @param errorMessage The error message string, which may be JSON.
|
|
* @returns The suggested delay in milliseconds, or null if not found.
|
|
*/
|
|
private _parseRetryDelayFromString(errorMessage: string): number | null {
|
|
try {
|
|
// The error message is a JSON string, so we parse it.
|
|
const errorObj = JSON.parse(errorMessage);
|
|
const details = errorObj?.error?.details;
|
|
|
|
if (!Array.isArray(details)) {
|
|
return null;
|
|
}
|
|
|
|
// Find the retry info object in the details array
|
|
const retryInfo = details.find(
|
|
(d: any) => d['@type'] === 'type.googleapis.com/google.rpc.RetryInfo'
|
|
);
|
|
|
|
const delayStr = retryInfo?.retryDelay;
|
|
if (typeof delayStr !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
// Parse strings like "15s" or "12.5s"
|
|
const match = delayStr.match(/^(\d+(\.\d+)?)(s|ms)?$/);
|
|
if (!match) {
|
|
console.warn(`Could not parse retryDelay string: ${delayStr}`);
|
|
return null;
|
|
}
|
|
|
|
const value = parseFloat(match[1]);
|
|
const unit = match[3];
|
|
|
|
if (unit === 'ms') {
|
|
return value;
|
|
}
|
|
|
|
// Default to seconds (e.g., "15s" or just "15")
|
|
return value * 1000;
|
|
|
|
} catch (parseError) {
|
|
// This error was not the JSON we were expecting.
|
|
// This is fine, it's just not a 429 quota error.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replaces the content between the *first* pair of ordered 'start' and 'end'
|
|
* markers it finds in the contents array with "---REMOVED---".
|
|
* It prioritizes replacement based on the order of the provided marker pairs.
|
|
* This is done non-destructively by returning a new array.
|
|
* * @param contents The array of Content objects.
|
|
* @param orderedMarkerPairs An array of MarkerPair objects defining the order
|
|
* of markers to search for and replace.
|
|
* @returns A new, modified array of Content objects.
|
|
*/
|
|
private async _reduceContents(
|
|
contents: Content[],
|
|
orderedMarkerPairs: MarkerPair[]
|
|
): Promise<Content[]> {
|
|
let replacedOne = false;
|
|
|
|
// Use a standard replacement text
|
|
const replacementText = '---TOOL RESPONSE REMOVED---';
|
|
|
|
// 1. Iterate through the ordered marker pairs
|
|
for (const markerPair of orderedMarkerPairs) {
|
|
const { begin: toolBeginMarker, end: toolEndMarker } = markerPair;
|
|
|
|
// Reset state for a clean pass for this marker pair
|
|
const intermediateContents: Content[] = [];
|
|
replacedOne = false;
|
|
|
|
// 2. Iterate through the contents array to find the *first* match for the current marker pair
|
|
for (const content of contents) {
|
|
// Optimization: If a replacement was already made for this marker pair, just copy the rest
|
|
if (replacedOne) {
|
|
intermediateContents.push(content);
|
|
continue;
|
|
}
|
|
|
|
const newParts: Part[] = [];
|
|
let partReplaced = false;
|
|
|
|
if (content.parts) {
|
|
for (const part of content.parts) {
|
|
if ('text' in part && !partReplaced) {
|
|
const text = part.text;
|
|
if (text) {
|
|
const startIndex = text.indexOf(toolBeginMarker);
|
|
|
|
if (startIndex !== -1) {
|
|
// Found the begin marker, now look for the end marker *after* it
|
|
const endIndex = text.indexOf(toolEndMarker, startIndex + toolBeginMarker.length);
|
|
|
|
if (endIndex !== -1) {
|
|
// Found a complete block!
|
|
const pre = text.substring(0, startIndex);
|
|
const post = text.substring(endIndex + toolEndMarker.length);
|
|
|
|
// Create the new part with the replacement text
|
|
newParts.push({ text: pre + replacementText + post });
|
|
|
|
// Mark that the overall reduction is complete for this call
|
|
replacedOne = true;
|
|
partReplaced = true;
|
|
continue; // Move to the next part/content
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// If no replacement was made in this part, or not a text part, push the original part
|
|
newParts.push(part);
|
|
}
|
|
}
|
|
|
|
// Push the modified/unmodified content object to the intermediate array
|
|
intermediateContents.push({ ...content, parts: newParts });
|
|
}
|
|
|
|
// 3. Check if a replacement was made using this specific marker pair
|
|
if (replacedOne) {
|
|
// A replacement was made (using the current markerPair).
|
|
// The logic dictates we only remove ONE block per function call,
|
|
// so we return the result immediately.
|
|
return intermediateContents;
|
|
}
|
|
|
|
// If no replacement was made, continue to the next marker pair.
|
|
}
|
|
|
|
if (!replacedOne) {
|
|
|
|
const indexToRemove = 3; // The fourth element is at index 3.
|
|
|
|
if (contents.length <= indexToRemove) {
|
|
// Cannot remove the 4th element if there are 3 or fewer elements.
|
|
console.warn(`Cannot perform secondary reduction: Contents array length is only ${contents.length}. Must have at least ${indexToRemove + 1} elements.`);
|
|
return contents;
|
|
}
|
|
|
|
// Create a non-destructive copy of the array.
|
|
const reducedContents = [...contents];
|
|
|
|
// Use splice() on the copy to remove 1 element starting at the specified index.
|
|
reducedContents.splice(indexToRemove, 1);
|
|
|
|
console.warn(`Performed secondary reduction: Removed the single content block at index ${indexToRemove} (the 4th element).`);
|
|
return reducedContents;
|
|
}
|
|
|
|
// 4. Fallback if no reduction was possible across all marker pairs
|
|
console.warn('Could not reduce contents further: No replaceable tool blocks found for any defined marker pairs.');
|
|
return contents;
|
|
}
|
|
|
|
public async trimToTokenLimit(
|
|
model: string,
|
|
data: string,
|
|
proportionOfLimit: number
|
|
): Promise<string> {
|
|
|
|
// 1. Get the generator and model limits
|
|
const contentGenerator = await this.getContentGenerator(model);
|
|
const modelInfo = await contentGenerator.get({ model });
|
|
const contextWindowSize = modelInfo?.inputTokenLimit ?? 1_048_576; // Default to 1M if undefined
|
|
const maxTokens = Math.floor(contextWindowSize * proportionOfLimit);
|
|
|
|
// 2. Count tokens in the specific data string
|
|
const { totalTokens } = await contentGenerator.countTokens({
|
|
model,
|
|
contents: [{ role: 'user', parts: [{ text: data }] }]
|
|
});
|
|
|
|
// 3. Check and Trim if strictly necessary
|
|
if (totalTokens && totalTokens > maxTokens) {
|
|
console.warn(`Data exceeds context window (${totalTokens} > ${contextWindowSize}). Trimming to 90% (~${maxTokens} tokens).`);
|
|
|
|
// Calculate ratio to slice string (Character approximation based on token overage)
|
|
const ratio = maxTokens / totalTokens;
|
|
const newLength = Math.floor(data.length * ratio);
|
|
|
|
data = data.substring(0, newLength) + '\n...[TRIMMED DUE TO CONTEXT LIMIT]...';
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Private method to handle API calls with retry, exponential backoff,
|
|
* and context size management.
|
|
*/
|
|
private async _generateContentWithRetries(
|
|
model: string,
|
|
contents: Content[],
|
|
generateConfig: GenerateContentConfig,
|
|
signal?: AbortSignal,
|
|
): Promise<GenerateContentResponse> {
|
|
let contentGenerator: ContentGenerator;
|
|
try {
|
|
contentGenerator = await this.getContentGenerator(model);
|
|
} catch (e) {
|
|
return this._createErrorResponse(
|
|
`GeminiClient failed to initialize ContentGenerator for model ${model}: ${
|
|
e instanceof Error ? e.message : String(e)
|
|
}`,
|
|
);
|
|
}
|
|
|
|
let currentContents = [...contents];
|
|
|
|
// --- Proactive Context Size Management ---
|
|
try {
|
|
const modelInfo = await contentGenerator.get({model: model})
|
|
const maxTokens = modelInfo?.inputTokenLimit;
|
|
|
|
if (maxTokens) {
|
|
const toolBeginMarker = await this.config.context.getToolResultPrefix();
|
|
const toolEndMarker = await this.config.context.getToolResultSuffix();
|
|
|
|
const markers = [
|
|
{ begin: toolBeginMarker, end: toolEndMarker }, // Pair 1 (Highest priority)
|
|
{ begin: "@DOC/EDIT{", end: "END_EDIT" }, // Pair 2
|
|
];
|
|
|
|
|
|
const limit = maxTokens * 0.99; // Be conservative
|
|
|
|
let countRequest: any = { model: model, contents: currentContents };
|
|
let tokenCheck = await contentGenerator.countTokens(countRequest);
|
|
let currentTokens = tokenCheck.totalTokens;
|
|
|
|
while (currentTokens && currentTokens > limit) {
|
|
console.warn(`Proactively reducing context size. Current: ${currentTokens}, Limit: ${Math.floor(limit)}`);
|
|
|
|
currentContents = await this._reduceContents(currentContents, markers);
|
|
|
|
countRequest = { model: model, contents: currentContents };
|
|
tokenCheck = await contentGenerator.countTokens(countRequest);
|
|
let newTokens = tokenCheck.totalTokens;
|
|
|
|
if (newTokens === currentTokens) {
|
|
// No reduction happened, or reduction didn't save tokens.
|
|
console.error('Failed to reduce context size. No replaceable blocks found or reduction was ineffective. Aborting Task.');
|
|
throw new Error('Context reduction failed: Cannot proceed with content generation.');
|
|
}
|
|
currentTokens = newTokens;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error during proactive token count:', error);
|
|
// Proceed anyway and let the reactive check handle it
|
|
}
|
|
// --- End Proactive Check ---
|
|
|
|
try {
|
|
await this.apiPolicyManager.trackAndApplyPolicy(this.apiName, model);
|
|
} catch (error) {
|
|
if (error instanceof LlmBlockedError) {
|
|
throw error;
|
|
}
|
|
|
|
return this._createErrorResponse(
|
|
`ApiPolicyManager check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
|
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
try {
|
|
// Abort handling for each attempt
|
|
if (signal?.aborted) {
|
|
throw new DOMException('Request aborted before sending.', 'AbortError');
|
|
}
|
|
|
|
const apiCallPromise = contentGenerator.generateContent({
|
|
model,
|
|
contents: currentContents,
|
|
config: generateConfig,
|
|
} as any);
|
|
|
|
let response: GenerateContentResponse;
|
|
|
|
if (signal) {
|
|
let abortHandler: () => void;
|
|
|
|
const abortPromise = new Promise<never>((_, reject) => {
|
|
abortHandler = () =>
|
|
reject(new DOMException('Request aborted by user.', 'AbortError'));
|
|
signal.addEventListener('abort', abortHandler, { once: true });
|
|
});
|
|
|
|
try {
|
|
response = await Promise.race([apiCallPromise, abortPromise]);
|
|
} finally {
|
|
signal.removeEventListener('abort', abortHandler!);
|
|
}
|
|
} else {
|
|
response = await apiCallPromise;
|
|
}
|
|
|
|
// A successful response must have candidates. If not, treat as a failure.
|
|
if (!response?.candidates || response.candidates.length === 0) {
|
|
throw new Error('Invalid or empty response from API.');
|
|
}
|
|
this.updateTokenUsage(model, response);
|
|
this.apiPolicyManager.reportApiSuccess(this.apiName, model);
|
|
|
|
const firstCandidate = response.candidates[0];
|
|
if (firstCandidate?.content?.parts) {
|
|
for (const part of firstCandidate.content.parts) {
|
|
if (part.text) {
|
|
const runawayLoopPattern = /(.)\1{50,}\s*$/;
|
|
|
|
if (runawayLoopPattern.test(part.text)) {
|
|
part.text = "[System Note: This response was discarded because it entered a runaway token loop (excessive character repetition). Please resume the task and ensure formatting is concise and does not use excessive repeated characters.]";
|
|
} else {
|
|
part.text = part.text.replace(/-{10,}/g, '---');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return response;
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
return this._createErrorResponse('Request aborted.');
|
|
}
|
|
|
|
try {
|
|
this.apiPolicyManager.reportApiFailure(this.apiName, model);
|
|
} catch (apiError) {
|
|
console.error('CRITICAL: apiPolicyManager.reportApiFailure failed:', apiError);
|
|
}
|
|
|
|
// --- Reactive Context Size Management ---
|
|
// Check for 400 Bad Request indicating prompt is too long
|
|
const isContextError = errorMessage.includes('400') &&
|
|
(errorMessage.includes('exceeds the limit') ||
|
|
errorMessage.includes('context length') ||
|
|
errorMessage.includes('prompt is too long'));
|
|
|
|
if (isContextError && attempt < MAX_ATTEMPTS) {
|
|
const toolBeginMarker = await this.config.context.getToolResultPrefix();
|
|
const toolEndMarker = await this.config.context.getToolResultSuffix();
|
|
|
|
const markers = [
|
|
{ begin: toolBeginMarker, end: toolEndMarker }, // Pair 1 (Highest priority)
|
|
{ begin: "@DOC/EDIT{", end: "END_EDIT" }, // Pair 2
|
|
];
|
|
|
|
console.warn(`Attempt ${attempt} failed with context size error. Reactively reducing content and retrying immediately.`);
|
|
currentContents = await this._reduceContents(currentContents, markers);
|
|
continue; // Skip backoff, retry immediately with reduced content
|
|
}
|
|
// --- End Reactive Check ---
|
|
|
|
if (attempt === MAX_ATTEMPTS) {
|
|
return this._createErrorResponse(`Final attempt (${attempt}) failed. Error: ${errorMessage}`);
|
|
}
|
|
|
|
let delay: number;
|
|
const defaultDelay = Math.pow(2, attempt) * 1000;
|
|
|
|
// Try to parse the API-suggested delay
|
|
const apiDelayMs = this._parseRetryDelayFromString(errorMessage);
|
|
|
|
if (apiDelayMs !== null) {
|
|
// Use API suggested delay + 1s buffer
|
|
delay = apiDelayMs + 1000;
|
|
console.log(
|
|
`API suggested retry delay of ${apiDelayMs / 1000}s. Waiting ${
|
|
delay / 1000
|
|
}s...`
|
|
);
|
|
} else {
|
|
// Fallback to exponential backoff
|
|
delay = defaultDelay;
|
|
}
|
|
|
|
console.warn(
|
|
`Attempt ${attempt} failed with error: ${errorMessage}. Retrying in ${
|
|
delay / 1000
|
|
}s...`,
|
|
);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
return this._createErrorResponse('All retry attempts failed unexpectedly.');
|
|
}
|
|
|
|
/**
|
|
* Sends a single "one-shot" message to the LLM.
|
|
*/
|
|
public async sendOneShotMessage(
|
|
prompt: string | Part[],
|
|
options?: GenerateContentOptions,
|
|
): Promise<GenerateContentResponse> {
|
|
const model = options?.model || DEFAULT_GEMINI_FLASH_MODEL;
|
|
|
|
const toolsArray: Tool[] = [];
|
|
if (options?.enableGrounding) {
|
|
toolsArray.push({ googleSearch: {} });
|
|
}
|
|
|
|
const generateConfig: GenerateContentConfig = {
|
|
temperature: options?.temperature,
|
|
tools: toolsArray.length > 0 ? toolsArray : undefined,
|
|
// thinkingConfig: options?.enableThinking ? { includeThoughts: true } : undefined,
|
|
};
|
|
if (options?.enableGrounding && generateConfig.temperature === undefined) {
|
|
generateConfig.temperature = 0;
|
|
}
|
|
|
|
const contents: Content[] = [
|
|
{
|
|
role: 'user',
|
|
parts: typeof prompt === 'string' ? [{ text: prompt }] : prompt,
|
|
},
|
|
];
|
|
|
|
return this._generateContentWithRetries(
|
|
model,
|
|
contents,
|
|
generateConfig,
|
|
options?.signal,
|
|
);
|
|
}
|
|
|
|
public async sendTranscriptMessage(
|
|
transcriptManager: TranscriptManager | undefined,
|
|
options?: GenerateContentOptions,
|
|
): Promise<GenerateContentResponse> {
|
|
const model = options?.model || DEFAULT_GEMINI_FLASH_MODEL;
|
|
|
|
const toolsArray: Tool[] = [];
|
|
|
|
// Handle Google Search (Grounding)
|
|
if (options?.enableGrounding) {
|
|
toolsArray.push({ googleSearch: {} });
|
|
}
|
|
|
|
// FIX: Map local tool types to SDK SchemaType Enums
|
|
if (options?.tools && options.tools.length > 0) {
|
|
toolsArray.push({
|
|
functionDeclarations: options.tools.map(tool => ({
|
|
name: tool.name,
|
|
description: tool.description,
|
|
parameters: {
|
|
type: Type.OBJECT,
|
|
properties: Object.entries(tool.parameters.properties).reduce((acc, [key, prop]) => {
|
|
acc[key] = {
|
|
type: Type.STRING, // Converting hardcoded 'string' to SchemaType.STRING
|
|
description: prop.description
|
|
};
|
|
return acc;
|
|
}, {} as any),
|
|
required: tool.parameters.required
|
|
}
|
|
}))
|
|
});
|
|
}
|
|
|
|
const generateConfig: GenerateContentConfig = {
|
|
temperature: options?.temperature,
|
|
tools: toolsArray.length > 0 ? toolsArray : undefined,
|
|
};
|
|
if (options?.enableGrounding) {
|
|
if (generateConfig.temperature === undefined) {
|
|
generateConfig.temperature = 0;
|
|
}
|
|
}
|
|
|
|
const contents: Content[] = this._mapFormattedTranscriptToContent(transcriptManager?.getTranscript() ?? []);
|
|
|
|
const response = await this._generateContentWithRetries(
|
|
model,
|
|
contents,
|
|
generateConfig,
|
|
options?.signal,
|
|
);
|
|
|
|
const modelContent = response.candidates?.[0]?.content;
|
|
if (modelContent) {
|
|
const hasFunctionCalls = modelContent.parts?.some(part => !!part.functionCall);
|
|
|
|
if (hasFunctionCalls) {
|
|
// Pass structured parts for function calls
|
|
transcriptManager?.addEntry(
|
|
modelContent.role ?? 'model',
|
|
modelContent.parts as any,
|
|
{},
|
|
false
|
|
);
|
|
} else {
|
|
const newTranscriptEntry = (modelContent.parts ?? [])
|
|
.map(part => ('text' in part ? part.text : ''))
|
|
.join('\n');
|
|
|
|
if (newTranscriptEntry === '---The Gemini API was unable to provide a response---') {
|
|
return response;
|
|
}
|
|
|
|
// Clean repetitive output before transcript management
|
|
const dedupedTranscriptEntry = this._removeRepetitiveBlocks(newTranscriptEntry);
|
|
|
|
const cleanTranscriptEntry =
|
|
(await transcriptManager?.cleanLLMResponse(dedupedTranscriptEntry)) ||
|
|
dedupedTranscriptEntry;
|
|
|
|
transcriptManager?.addEntry(
|
|
modelContent.role ?? 'model',
|
|
cleanTranscriptEntry,
|
|
{},
|
|
false,
|
|
);
|
|
|
|
if (response.candidates?.[0]?.content) {
|
|
response.candidates[0].content.parts = [{ text: cleanTranscriptEntry }];
|
|
}
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
} |