Skip to content

Feat: RoutedAgent and RoutedLlm#215

Open
ScottMansfield wants to merge 24 commits intomainfrom
feat/routed-agents-models
Open

Feat: RoutedAgent and RoutedLlm#215
ScottMansfield wants to merge 24 commits intomainfrom
feat/routed-agents-models

Conversation

@ScottMansfield
Copy link
Copy Markdown
Member

Draft PR for sharing

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

  • Closes: #issue_number
  • Related: #issue_number

2. Or, if no issue exists, describe the change:

If applicable, please follow the issue templates to provide as much detail as
possible.

Problem:
A clear and concise description of what the problem is.

Solution:
A clear and concise description of what you want to happen and why you choose
this solution.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required
for all PRs that are not small documentation or typo fixes.

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

Please include a summary of passed npm test results.

Manual End-to-End (E2E) Tests:

Please provide instructions on how to manually test your changes, including any
necessary setup or configuration. Please provide logs or screenshots to help
reviewers better understand the fix.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

Add any other context or screenshots about the feature request here.

Comment on lines +47 to +64
if (
runResult &&
typeof runResult === 'object' &&
typeof (runResult as AsyncIterable<unknown>)[Symbol.asyncIterator] ===
'function'
) {
const iterator = runResult as AsyncGenerator<TYield, void, void>;

for await (const result of iterator) {
yield result;
firstYielded = true;
}
break;
} else {
const result = await (runResult as Promise<TYield>);
yield result;
break;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be something like that:

const generatorOrPromise = runFn(selectedItem);

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

   return;
}

return await generatorOrPromise;

and below define an util:

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

Comment on lines +101 to +114
export function runWithRouting<T, C, R>(
items: Readonly<Record<string, T>>,
context: C,
router: Router<T, C>,
runFn: (item: T) => AsyncGenerator<R, void, void>,
): AsyncGenerator<R, void>;

// eslint-disable-next-line no-redeclare
export function runWithRouting<T, C, R>(
items: Readonly<Record<string, T>>,
context: C,
router: Router<T, C>,
runFn: (item: T) => Promise<R>,
): Promise<R>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need those as later you combine them

Comment on lines +101 to +122
export function runWithRouting<T, C, R>(
items: Readonly<Record<string, T>>,
context: C,
router: Router<T, C>,
runFn: (item: T) => AsyncGenerator<R, void, void>,
): AsyncGenerator<R, void>;

// eslint-disable-next-line no-redeclare
export function runWithRouting<T, C, R>(
items: Readonly<Record<string, T>>,
context: C,
router: Router<T, C>,
runFn: (item: T) => Promise<R>,
): Promise<R>;

// eslint-disable-next-line no-redeclare
export function runWithRouting<T, C, R>(
items: Readonly<Record<string, T>>,
context: C,
router: Router<T, C>,
runFn: (item: T) => AsyncGenerator<R, void, void> | Promise<R>,
): unknown {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export function runWithRouting<T, C, R>(
items: Readonly<Record<string, T>>,
context: C,
router: Router<T, C>,
runFn: (item: T) => AsyncGenerator<R, void, void>,
): AsyncGenerator<R, void>;
// eslint-disable-next-line no-redeclare
export function runWithRouting<T, C, R>(
items: Readonly<Record<string, T>>,
context: C,
router: Router<T, C>,
runFn: (item: T) => Promise<R>,
): Promise<R>;
// eslint-disable-next-line no-redeclare
export function runWithRouting<T, C, R>(
items: Readonly<Record<string, T>>,
context: C,
router: Router<T, C>,
runFn: (item: T) => AsyncGenerator<R, void, void> | Promise<R>,
): unknown {
export function runWithRouting<T, C, R>(
items: Readonly<Record<string, T>>,
context: C,
router: Router<T, C>,
runFn: (item: T) => AsyncGenerator<R, void, void> | Promise<R>,
): AsyncGenerator<R, void, void> | Promise<R> {

}
})();
return p.then(onfulfilled, onrejected);
},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

honestly, this looks overcomplicated

Comment on lines +125 to +145
return {
then<TResult1 = R, TResult2 = never>(
onfulfilled?: ((value: R) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?:
| ((reason: unknown) => TResult2 | PromiseLike<TResult2>)
| null,
): Promise<TResult1 | TResult2> {
const p = (async () => {
let savedValue: R | undefined;
while (true) {
const result = await gen.next();
if (result.done) {
return (
result.value !== undefined ? result.value : savedValue
) as R;
}
savedValue = result.value as R;
}
})();
return p.then(onfulfilled, onrejected);
},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can use just a runWithRoutingCore function immediately, you don't event need this overhead.

All you need is to return from runWithRoutingCore not only generator but also a promise as suggested here: https://github.com/google/adk-js/pull/215/changes#r3024976474

Copy link
Copy Markdown
Collaborator

@kalenkevich kalenkevich Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final file changes after combining all the comments:

/**
 * @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 an operation with selection and failover support.
 * Overloaded to support both AsyncGenerator and Promise-returning functions.
 */
async function* runWithRouting<T, C, R>(
  items: Readonly<Record<string, T>>,
  context: C,
  router: Router<T, C>,
  runFn: (item: T) => AsyncGenerator<R, void, void> | Promise<R>,
): AsyncGenerator<R, void, void> | Promise<R> {
  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 runResult = runFn(selectedItem);
    let firstYielded = false;

    try {
      const generatorOrPromise = runFn(selectedItem);
      if (isAsyncGenerator(generatorOrPromise)) {
        for await (const result of generatorOrPromise) {
           yield result;
           firstYielded = true;
         }
      
         return;
      }
      
      return await generatorOrPromise;
    } 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'
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants