fix(core): strongly-typed ADK Model pass-through and session tracking

- Removed 'as any' castings and replaced with strongly-typed 'as unknown as T' bounds with safe ESLint exceptions.
- Renamed synthetic 'synth-' invocation IDs to descriptive session and history domain states.
- Linked local @google/adk workspace dependency.
This commit is contained in:
Adam Weidman
2026-05-21 16:50:37 -04:00
parent b213fd68ec
commit 3ee7d6f1a0
5 changed files with 311 additions and 15 deletions
+53
View File
@@ -86,6 +86,54 @@
"node-pty": "^1.0.0"
}
},
"../adk-js/core": {
"name": "@google/adk",
"version": "1.1.0",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.10",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google-cloud/storage": "^7.17.1",
"@google-cloud/vertexai": "^1.12.0",
"@google/genai": "^1.37.0",
"@mikro-orm/core": "^6.6.10",
"@mikro-orm/reflection": "^6.6.6",
"@modelcontextprotocol/sdk": "^1.26.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/api-logs": "^0.205.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.205.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.205.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.205.0",
"@opentelemetry/resource-detector-gcp": "^0.40.0",
"@opentelemetry/resources": "^2.1.0",
"@opentelemetry/sdk-logs": "^0.205.0",
"@opentelemetry/sdk-metrics": "^2.1.0",
"@opentelemetry/sdk-trace-base": "^2.1.0",
"@opentelemetry/sdk-trace-node": "^2.1.0",
"express": "^4.22.1",
"google-auth-library": "^10.3.0",
"js-yaml": "^4.1.1",
"jsonpath-plus": "^10.4.0",
"lodash-es": "^4.18.1",
"winston": "^3.19.0",
"zod": "^4.2.1",
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
"@mikro-orm/sqlite": "^6.6.6",
"@types/express": "^4.17.25",
"@types/lodash-es": "^4.17.12",
"openapi-types": "^12.1.3"
},
"peerDependencies": {
"@mikro-orm/mariadb": "^6.6.6",
"@mikro-orm/mssql": "^6.6.6",
"@mikro-orm/mysql": "^6.6.6",
"@mikro-orm/postgresql": "^6.6.6",
"@mikro-orm/sqlite": "^6.6.6"
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.16.1.tgz",
@@ -1398,6 +1446,10 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/@google/adk": {
"resolved": "../adk-js/core",
"link": true
},
"node_modules/@google/gemini-cli": {
"resolved": "packages/cli",
"link": true
@@ -18408,6 +18460,7 @@
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google/adk": "file:../../../adk-js/core",
"@google/genai": "1.30.0",
"@grpc/grpc-js": "^1.14.3",
"@iarna/toml": "^2.2.5",
+1
View File
@@ -23,6 +23,7 @@
"dist"
],
"dependencies": {
"@google/adk": "file:../../../adk-js/core",
"@a2a-js/sdk": "0.3.11",
"@bufbuild/protobuf": "^2.11.0",
"@google-cloud/logging": "^11.2.1",
+28 -6
View File
@@ -30,6 +30,8 @@ import { getCoreSystemPrompt } from './prompts.js';
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { reportError } from '../utils/errorReporting.js';
import { GeminiChat } from './geminiChat.js';
import { GcliAdkModel } from './gcliAdkModel.js';
import type { LlmRequest } from '@google/adk';
import {
retryWithBackoff,
type RetryAvailabilityContext,
@@ -1101,15 +1103,35 @@ export class GeminiClient {
systemInstruction,
};
return this.getContentGeneratorOrFail().generateContent(
{
model: currentAttemptModel,
config: requestConfig,
contents,
},
const adkModel = new GcliAdkModel(
this.getContentGeneratorOrFail(),
this.lastPromptId,
role,
currentAttemptModel,
);
const adkRequest: LlmRequest = {
contents,
config: requestConfig,
liveConnectConfig: {},
toolsDict: {},
};
const adkStream = adkModel.generateContentAsync(
adkRequest,
false,
abortSignal,
);
const processResponse = async () => {
const nextVal = await adkStream.next();
if (nextVal.done || !nextVal.value) {
throw new Error('No response from ADK model.');
}
return nextVal.value.rawResponse;
};
return processResponse();
};
const onPersistent429Callback = async (
authType?: string,
+132
View File
@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseLlm,
type LlmRequest,
type LlmResponse,
type BaseLlmConnection,
} from '@google/adk';
import type {
GenerateContentResponse,
Content,
GenerateContentConfig,
} from '@google/genai';
import type { ContentGenerator } from './contentGenerator.js';
import type { LlmRole } from '../telemetry/llmRole.js';
export interface GcliLlmResponse extends LlmResponse {
rawResponse: GenerateContentResponse;
}
/**
* Creates an LlmResponse from a GenerateContentResponse.
* Locally defined to avoid dependency export resolution issues.
*/
function localCreateLlmResponse(
response: GenerateContentResponse,
): LlmResponse {
const usageMetadata = response.usageMetadata;
if (response.candidates && response.candidates.length > 0) {
const candidate = response.candidates[0];
if (candidate.content?.parts && candidate.content.parts.length > 0) {
return {
content: candidate.content,
groundingMetadata: candidate.groundingMetadata,
citationMetadata: candidate.citationMetadata,
usageMetadata,
finishReason: candidate.finishReason,
};
}
return {
errorCode: candidate.finishReason,
errorMessage: candidate.finishMessage,
usageMetadata,
citationMetadata: candidate.citationMetadata,
finishReason: candidate.finishReason,
};
}
if (response.promptFeedback) {
return {
errorCode: response.promptFeedback.blockReason,
errorMessage: response.promptFeedback.blockReasonMessage,
usageMetadata,
};
}
return {
errorCode: 'UNKNOWN_ERROR',
errorMessage: 'Unknown error.',
usageMetadata,
};
}
export class GcliAdkModel extends BaseLlm {
constructor(
private readonly contentGenerator: ContentGenerator,
private readonly promptId: string,
private readonly role: LlmRole,
modelName: string,
) {
super({ model: modelName });
}
async *generateContentAsync(
llmRequest: LlmRequest,
stream = true,
abortSignal?: AbortSignal,
): AsyncGenerator<GcliLlmResponse, void> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const contents = llmRequest.contents as unknown as Content[];
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = {
...llmRequest.config,
abortSignal,
} as unknown as GenerateContentConfig;
if (stream) {
const responseStream = await this.contentGenerator.generateContentStream(
{
model: this.model,
contents,
config,
},
this.promptId,
this.role,
);
for await (const chunk of responseStream) {
const adkResponse = localCreateLlmResponse(chunk);
yield {
...adkResponse,
rawResponse: chunk,
};
}
} else {
const response = await this.contentGenerator.generateContent(
{
model: this.model,
contents,
config,
},
this.promptId,
this.role,
);
const adkResponse = localCreateLlmResponse(response);
yield {
...adkResponse,
rawResponse: response,
};
}
}
async connect(_llmRequest: LlmRequest): Promise<BaseLlmConnection> {
throw new Error('Bidi connections not supported in Dumb Model Swap.');
}
}
+97 -9
View File
@@ -60,6 +60,13 @@ import {
import { coreEvents } from '../utils/events.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { debugLogger } from '../utils/debugLogger.js';
import {
createSession,
createEvent,
type Session,
type LlmRequest,
} from '@google/adk';
import { GcliAdkModel } from './gcliAdkModel.js';
export enum StreamEventType {
/** A regular content chunk from the API. */
@@ -267,6 +274,7 @@ export class GeminiChat {
private lastPromptTokenCount: number;
private callCounter = 0;
agentHistory: AgentChatHistory;
private readonly adkSession: Session;
constructor(
readonly context: AgentLoopContext,
@@ -282,6 +290,18 @@ export class GeminiChat {
this.lastPromptTokenCount = estimateTokenCountSync(
this.agentHistory.flatMap((c) => c.parts || []),
);
this.adkSession = createSession({
id: context.config.getSessionId() || 'default-adk-session',
appName: 'Gemini CLI',
userId: 'gemini-user',
events: history.map((content) =>
createEvent({
invocationId: context.config.getSessionId() || 'session-init',
author: content.role,
content,
}),
),
});
}
get loopContext(): AgentLoopContext {
@@ -375,9 +395,16 @@ export class GeminiChat {
if (binaryInjections) {
// Turn 1: The original tool response (now cleaned)
this.agentHistory.push(userContent);
this.adkSession.events.push(
createEvent({
invocationId: prompt_id,
author: userContent.role,
content: userContent,
}),
);
// Turn 2: Synthetic Model Acknowledgment
this.agentHistory.push({
const ackContent: Content = {
role: 'model',
parts: [
{
@@ -386,7 +413,15 @@ export class GeminiChat {
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
},
],
});
};
this.agentHistory.push(ackContent);
this.adkSession.events.push(
createEvent({
invocationId: prompt_id,
author: 'model',
content: ackContent,
}),
);
// Turn 3: The actual binary data (becomes the current request message)
userContent = {
@@ -396,6 +431,13 @@ export class GeminiChat {
}
this.agentHistory.push(userContent);
this.adkSession.events.push(
createEvent({
invocationId: prompt_id,
author: userContent.role,
content: userContent,
}),
);
const requestContents = this.getHistory(true);
const streamWithRetries = async function* (
@@ -740,15 +782,33 @@ export class GeminiChat {
const finalContents = stripToolCallIdPrefixes(contentsToUse);
return this.context.config.getContentGenerator().generateContentStream(
{
model: modelToUse,
contents: finalContents,
config,
},
const adkModel = new GcliAdkModel(
this.context.config.getContentGenerator(),
prompt_id,
role,
modelToUse,
);
const adkRequest: LlmRequest = {
contents: finalContents,
config,
liveConnectConfig: {},
toolsDict: {},
};
const adkStream = adkModel.generateContentAsync(
adkRequest,
true,
abortSignal,
);
const responseGenerator = async function* () {
for await (const adkResponse of adkStream) {
yield adkResponse.rawResponse;
}
};
return responseGenerator();
};
const onPersistent429Callback = async (
@@ -803,6 +863,7 @@ export class GeminiChat {
lastModelToUse,
streamResponse,
originalRequest,
prompt_id,
);
}
@@ -844,6 +905,7 @@ export class GeminiChat {
*/
clearHistory(): void {
this.agentHistory.clear();
this.adkSession.events = [];
}
/**
@@ -851,6 +913,13 @@ export class GeminiChat {
*/
addHistory(content: Content): void {
this.agentHistory.push(content);
this.adkSession.events.push(
createEvent({
invocationId: this.context.config.getSessionId() || 'history-add',
author: content.role,
content,
}),
);
}
setHistory(
@@ -862,6 +931,13 @@ export class GeminiChat {
this.agentHistory.flatMap((c) => c.parts || []),
);
this.chatRecordingService.updateMessagesFromHistory(history);
this.adkSession.events = history.map((content) =>
createEvent({
invocationId: this.context.config.getSessionId() || 'history-sync',
author: content.role,
content,
}),
);
}
stripThoughtsFromHistory(): void {
@@ -968,6 +1044,7 @@ export class GeminiChat {
model: string,
streamResponse: AsyncGenerator<GenerateContentResponse>,
originalRequest: GenerateContentParameters,
prompt_id: string,
): AsyncGenerator<GenerateContentResponse> {
const modelResponseParts: Part[] = [];
@@ -1208,7 +1285,18 @@ export class GeminiChat {
}
}
this.agentHistory.push({ role: 'model', parts: consolidatedParts });
const consolidatedContent: Content = {
role: 'model',
parts: consolidatedParts,
};
this.agentHistory.push(consolidatedContent);
this.adkSession.events.push(
createEvent({
invocationId: prompt_id,
author: 'model',
content: consolidatedContent,
}),
);
}
getLastPromptTokenCount(): number {