-
Notifications
You must be signed in to change notification settings - Fork 120
Feat: RoutedAgent and RoutedLlm #215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 4236b91
Fixed routed agent tests
ScottMansfield 95b374c
Correctly naming test files
ScottMansfield 057b133
Fixing routed LLM tests
ScottMansfield a1f464a
Adding the map of LLMs and Agents to the callbacks
ScottMansfield 8091c29
Correct test imports and add symbol function for RoutedAgent
ScottMansfield 6469748
Adding integration tests for RoutedAgent and RoutedLlm
ScottMansfield d149cfa
Renaming selector to router
ScottMansfield c877420
Adding a re-route mechanism for LLM failures
ScottMansfield 1feae9c
Adding a re-route mechanism for Agent failures
ScottMansfield 93e95a3
Merge branch 'main' into feat/routed-agents-models
ScottMansfield ba1043d
Adding test to verify shared session in RoutedAgent. Also had gemini …
ScottMansfield c6dafe1
Correct copyright years in headers
ScottMansfield 89c95c3
Use Record instead of Map to hold agents and LLMs
ScottMansfield 04c613e
Refactoring RoutedAgent to reduce code duplication
ScottMansfield b95018c
Reduce code duplication by factoring out the selection and retry logic
ScottMansfield eb7aa4d
Merge branch 'main' into feat/routed-agents-models
ScottMansfield a373e01
Combining retry logic into one function with overloads
ScottMansfield cf84e83
Use for await loop instead of manual iterator handling
ScottMansfield 776d76f
Better logging for selection and model names
ScottMansfield 3328816
Adding e2e tests for various scenarios
ScottMansfield 5ad3195
Prevent infinite loops in model requests
ScottMansfield 8224937
Various fixes for types, loop exiting, return values
ScottMansfield 709cb11
Merge branch 'main' into feat/routed-agents-models
ScottMansfield eddfa48
Simplifying the failover utils function
ScottMansfield 411ae70
Merge branch 'main' into feat/routed-agents-models
ScottMansfield File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
|
ScottMansfield marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> { | ||
|
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; | ||
|
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' | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.