Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bdb3cc5
First pass of a routed agent and llm
ScottMansfield Mar 25, 2026
4236b91
Fixed routed agent tests
ScottMansfield Mar 25, 2026
95b374c
Correctly naming test files
ScottMansfield Mar 25, 2026
057b133
Fixing routed LLM tests
ScottMansfield Mar 25, 2026
a1f464a
Adding the map of LLMs and Agents to the callbacks
ScottMansfield Mar 25, 2026
8091c29
Correct test imports and add symbol function for RoutedAgent
ScottMansfield Mar 25, 2026
6469748
Adding integration tests for RoutedAgent and RoutedLlm
ScottMansfield Mar 26, 2026
d149cfa
Renaming selector to router
ScottMansfield Mar 26, 2026
c877420
Adding a re-route mechanism for LLM failures
ScottMansfield Mar 26, 2026
1feae9c
Adding a re-route mechanism for Agent failures
ScottMansfield Mar 26, 2026
93e95a3
Merge branch 'main' into feat/routed-agents-models
ScottMansfield Mar 26, 2026
ba1043d
Adding test to verify shared session in RoutedAgent. Also had gemini …
ScottMansfield Mar 26, 2026
c6dafe1
Correct copyright years in headers
ScottMansfield Mar 27, 2026
89c95c3
Use Record instead of Map to hold agents and LLMs
ScottMansfield Mar 27, 2026
04c613e
Refactoring RoutedAgent to reduce code duplication
ScottMansfield Mar 27, 2026
b95018c
Reduce code duplication by factoring out the selection and retry logic
ScottMansfield Mar 27, 2026
eb7aa4d
Merge branch 'main' into feat/routed-agents-models
ScottMansfield Mar 31, 2026
a373e01
Combining retry logic into one function with overloads
ScottMansfield Mar 31, 2026
cf84e83
Use for await loop instead of manual iterator handling
ScottMansfield Mar 31, 2026
776d76f
Better logging for selection and model names
ScottMansfield Apr 1, 2026
3328816
Adding e2e tests for various scenarios
ScottMansfield Apr 1, 2026
5ad3195
Prevent infinite loops in model requests
ScottMansfield Apr 1, 2026
8224937
Various fixes for types, loop exiting, return values
ScottMansfield Apr 1, 2026
709cb11
Merge branch 'main' into feat/routed-agents-models
ScottMansfield Apr 1, 2026
eddfa48
Simplifying the failover utils function
ScottMansfield Apr 6, 2026
411ae70
Merge branch 'main' into feat/routed-agents-models
ScottMansfield Apr 6, 2026
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
109 changes: 109 additions & 0 deletions core/src/agents/routed_agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {Event} from '../events/event.js';
import {BaseAgent, BaseAgentConfig} from './base_agent.js';
import {InvocationContext} from './invocation_context.js';

import {runWithRouting} from '../utils/failover_utils.js';

/**
* A unique symbol to identify ADK agent classes.
* Defined once and shared by all RoutedAgent instances.
*/
const ROUTED_AGENT_SIGNATURE_SYMBOL = Symbol.for('google.adk.routedAgent');

/**
* Type guard to check if an object is an instance of RoutedAgent.
* @param obj The object to check.
* @returns True if the object is an instance of RoutedAgent, false otherwise.
*/
export function isRoutedAgent(obj: unknown): obj is RoutedAgent {
return (
typeof obj === 'object' &&
obj !== null &&
ROUTED_AGENT_SIGNATURE_SYMBOL in obj &&
obj[ROUTED_AGENT_SIGNATURE_SYMBOL] === true
);
}

/**
* Type definition for a function that selects an agent based on the invocation context.
*/
export type AgentRouter = (
agents: Readonly<Record<string, BaseAgent>>,
context: InvocationContext,
errorContext?: {failedKeys: ReadonlySet<string>; lastError: unknown},
) => Promise<string | undefined> | string | undefined;

/**
* Configuration for the RoutingAgent.
*/
export interface RoutedAgentConfig extends BaseAgentConfig {
/**
* The set of agents to route to. Can be an array of agents or a Record of keys to agents.
* If an array is provided, the agent names will be used as keys.
*/
agents: Readonly<Record<string, BaseAgent>> | BaseAgent[];

/**
* The function to select which agent to run.
*/
router: AgentRouter;
}

/**
* A BaseAgent implementation that delegates to one of multiple agents based on a router function.
* Routing is strictly limited to the agents passed in the config.
*/
export class RoutedAgent extends BaseAgent {
readonly [ROUTED_AGENT_SIGNATURE_SYMBOL] = true;

private readonly agents: Readonly<Record<string, BaseAgent>>;
private readonly router: AgentRouter;

constructor(config: RoutedAgentConfig) {
const agentsArray = Array.isArray(config.agents)
? config.agents
: Object.values(config.agents);

// We pass the agents to super as subAgents to maintain the tree structure (parent tracking),
// but our routing logic strictly uses the internal map.
super({
...config,
subAgents: agentsArray,
});

if (Array.isArray(config.agents)) {
this.agents = Object.fromEntries(config.agents.map((a) => [a.name, a]));
} else {
this.agents = config.agents;
}
this.router = config.router;
}

/**
* Runs the selected agent via text-based conversation.
*/
protected async *runAsyncImpl(
context: InvocationContext,
): AsyncGenerator<Event, void, void> {
yield* runWithRouting(this.agents, context, this.router, (agent) =>
agent.runAsync(context),
);
}

/**
* Runs the selected agent via video/audio-based conversation.
*/
protected async *runLiveImpl(
context: InvocationContext,
): AsyncGenerator<Event, void, void> {
yield* runWithRouting(this.agents, context, this.router, (agent) =>
agent.runLive(context),
);
}
}
4 changes: 4 additions & 0 deletions core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export {
} from './agents/processors/content_request_processor.js';
export {ContextCompactorRequestProcessor} from './agents/processors/context_compactor_request_processor.js';
export {ReadonlyContext} from './agents/readonly_context.js';
export {RoutedAgent, isRoutedAgent} from './agents/routed_agent.js';
export type {AgentRouter, RoutedAgentConfig} from './agents/routed_agent.js';
export {StreamingMode} from './agents/run_config.js';
export type {RunConfig} from './agents/run_config.js';
export {SequentialAgent, isSequentialAgent} from './agents/sequential_agent.js';
Expand Down Expand Up @@ -152,6 +154,8 @@ export type {LlmRequest} from './models/llm_request.js';
export type {LlmResponse} from './models/llm_response.js';
export {LLMRegistry} from './models/registry.js';
export type {BaseLlmType} from './models/registry.js';
export {RoutedLlm} from './models/routed_llm.js';
export type {LlmRouter} from './models/routed_llm.js';
export {BasePlugin} from './plugins/base_plugin.js';
export {LoggingPlugin} from './plugins/logging_plugin.js';
export {PluginManager} from './plugins/plugin_manager.js';
Expand Down
2 changes: 1 addition & 1 deletion core/src/models/google_llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class Gemini extends BaseLlm {
this.preprocessRequest(llmRequest);
this.maybeAppendUserContent(llmRequest);
logger.info(
`Sending out request, model: ${llmRequest.model}, backend: ${this.apiBackend}, stream: ${stream}`,
`Sending out request, model: ${llmRequest.model ?? this.model}, backend: ${this.apiBackend}, stream: ${stream}`,
);

if (llmRequest.config?.httpOptions) {
Expand Down
85 changes: 85 additions & 0 deletions core/src/models/routed_llm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {BaseLlm} from './base_llm.js';
import {BaseLlmConnection} from './base_llm_connection.js';
import {LlmRequest} from './llm_request.js';
import {LlmResponse} from './llm_response.js';

import {runWithRouting} from '../utils/failover_utils.js';
import {logger} from '../utils/logger.js';

/**
* Type definition for a function that selects a model based on the request.
*/
export type LlmRouter = (
models: Readonly<Record<string, BaseLlm>>,
request: LlmRequest,
errorContext?: {failedKeys: ReadonlySet<string>; lastError: unknown},
) => Promise<string | undefined> | string | undefined;

/**
* A BaseLlm implementation that delegates to one of multiple models based on a router function.
*/
export class RoutedLlm extends BaseLlm {
private readonly models: Readonly<Record<string, BaseLlm>>;
private readonly router: LlmRouter;

constructor({
models,
router,
modelName = 'routed-llm',
}: {
models: Readonly<Record<string, BaseLlm>> | BaseLlm[];
router: LlmRouter;
modelName?: string;
}) {
const modelsMap = Array.isArray(models)
? Object.fromEntries(models.map((m) => [m.model, m]))
: models;

const modelNames = Object.entries(modelsMap).map(
([name, model]) => `${name} (${model.model})`,
);
const computedName = `RoutedLlm[${modelNames.join(', ')}]`;

super({model: modelName === 'routed-llm' ? computedName : modelName});
this.models = modelsMap;
this.router = router;
}

/**
* Generates content by delegating to the selected model.
*/
async *generateContentAsync(
llmRequest: LlmRequest,
stream?: boolean,
): AsyncGenerator<LlmResponse, void> {
logger.info(`Routing request via ${this.model}`);
yield* runWithRouting(this.models, llmRequest, this.router, (model) =>
model.generateContentAsync(llmRequest, stream),
);
}

/**
* Creates a live connection to the LLM by delegating to the selected model.
* This live connection cannot be switched mid-stream, it is tied to the model
* selected at the time of connection.
*/
async connect(llmRequest: LlmRequest): Promise<BaseLlmConnection> {
const generator = runWithRouting(
this.models,
llmRequest,
this.router,
(model) => model.connect(llmRequest),
);
const result = await generator.next();
if (result.done || result.value === undefined) {
throw new Error('Failed to establish connection: No connection yielded.');
}
return result.value;
Comment thread
ScottMansfield marked this conversation as resolved.
}
}
97 changes: 97 additions & 0 deletions core/src/utils/failover_utils.ts
Comment thread
ScottMansfield marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {logger} from './logger.js';

/**
* Type definition for a function that selects an item based on the context.
*/
export type Router<T, C> = (
items: Readonly<Record<string, T>>,
context: C,
errorContext?: {failedKeys: ReadonlySet<string>; lastError: unknown},
) => Promise<string | undefined> | string | undefined;

/**
* Runs a core operation with selection and failover support.
* Internal helper to unify Promise and Generator logic.
*/
export async function* runWithRouting<T, C, TYield>(
items: Readonly<Record<string, T>>,
context: C,
router: Router<T, C>,
runFn: (item: T) => AsyncGenerator<TYield, void, void> | Promise<TYield>,
): AsyncGenerator<TYield, void, void> {
Comment thread
ScottMansfield marked this conversation as resolved.
const initialKey = await router(items, context);
if (!initialKey) {
throw new Error('Initial routing failed, no item selected.');
}

let selectedKey = initialKey;
logger.debug(`Router selected initial key: ${selectedKey}`);
let selectedItem = items[selectedKey];
if (!selectedItem) {
throw new Error(`Item not found for key: ${selectedKey}`);
}

const triedKeys = new Set<string>([selectedKey]);

while (true) {
const generatorOrPromise = runFn(selectedItem);
let firstYielded = false;

try {
if (isAsyncGenerator(generatorOrPromise)) {
for await (const result of generatorOrPromise) {
yield result;
firstYielded = true;
}
return;
}

const result = await generatorOrPromise;
yield result;
return;
Comment thread
ScottMansfield marked this conversation as resolved.
} catch (error) {
if (!firstYielded) {
const nextKey = await router(items, context, {
failedKeys: triedKeys,
lastError: error,
});

logger.debug(`Router selected next key: ${nextKey}`);

// Router can return undefined to stop processing
if (nextKey === undefined) {
throw error;
}

// Disallow re-processing the same key in a single execution
if (triedKeys.has(nextKey)) {
throw error;
}

selectedKey = nextKey;
selectedItem = items[selectedKey];
if (!selectedItem) {
throw new Error(`Item not found for key: ${selectedKey}`);
}
triedKeys.add(selectedKey);
} else {
throw error;
}
}
}
}

function isAsyncGenerator(
obj: unknown,
): obj is AsyncGenerator<unknown, void, void> {
return (
typeof obj === 'object' &&
typeof (obj as AsyncIterable<unknown>)[Symbol.asyncIterator] === 'function'
);
}
Loading
Loading