Skip to content

feat(js/middleware): add handoff middleware for agent transfer pattern#5619

Draft
pavelgj wants to merge 2 commits into
mainfrom
pj/agents-handoff
Draft

feat(js/middleware): add handoff middleware for agent transfer pattern#5619
pavelgj wants to merge 2 commits into
mainfrom
pj/agents-handoff

Conversation

@pavelgj

@pavelgj pavelgj commented Jun 25, 2026

Copy link
Copy Markdown
Member

Introduce a new handoff middleware that enables the agent transfer/handoff pattern, where a host agent hosts multiple specialized personas and transfers control between them via generated transfer_to_<persona> tools. The active persona is tracked statelessly from conversation history, swapping system prompts and visible tools across turns.

  • Export handoff from the middleware package index
  • Document the middleware with options, usage, and limitations in README
  • Add a customer-service agent testapp example demonstrating the pattern

Introduce a new `handoff` middleware that enables the agent transfer/handoff
pattern, where a host agent hosts multiple specialized personas and transfers
control between them via generated `transfer_to_<persona>` tools. The active
persona is tracked statelessly from conversation history, swapping system
prompts and visible tools across turns.

- Export `handoff` from the middleware package index
- Document the middleware with options, usage, and limitations in README
- Add a customer-service agent testapp example demonstrating the pattern
@github-actions github-actions Bot added docs Improvements or additions to documentation js labels Jun 25, 2026
@pavelgj pavelgj changed the title feat(middleware): add handoff middleware for agent transfer pattern feat(js/middleware): add handoff middleware for agent transfer pattern Jun 25, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the handoff middleware, which enables the agent transfer/handoff pattern by allowing a host agent to dynamically swap active personas (system prompts and tools) during a conversation. It includes comprehensive unit tests, a demo customer service agent, and a React-based UI page to showcase the feature. The review feedback is highly constructive, pointing out critical issues such as incorrect tool filtering when request.tools contains objects instead of strings, and several potential TypeError crashes due to missing defensive checks on potentially undefined content and raw properties.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +364 to +366
const baseTools = (request.tools ?? []).filter(
(t) => !managedToolNames.has(t)
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The filtering of request.tools checks managedToolNames.has(t). However, request.tools can contain Tool or Action objects rather than just strings. Since managedToolNames is a Set<string>, checking has(t) on an object will always return false, preventing managed tools from being correctly filtered out. We should resolve the tool name before checking the set.

Suggested change
const baseTools = (request.tools ?? []).filter(
(t) => !managedToolNames.has(t)
);
const baseTools = (request.tools ?? []).filter((t) => {
const name = typeof t === 'string' ? t : (t as any)?.name;
return !name || !managedToolNames.has(name);
});

Comment on lines +247 to +251
function getActivePersona(messages: MessageData[]): Persona {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role !== 'tool') continue;
for (let j = m.content.length - 1; j >= 0; j--) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If m.content is undefined or null at runtime, accessing m.content.length will throw a TypeError. Adding a defensive check for m.content ensures robustness.

Suggested change
function getActivePersona(messages: MessageData[]): Persona {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role !== 'tool') continue;
for (let j = m.content.length - 1; j >= 0; j--) {
function getActivePersona(messages: MessageData[]): Persona {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role !== 'tool' || !m.content) continue;
for (let j = m.content.length - 1; j >= 0; j--) {

Comment on lines +372 to +374
for (let i = 0; i < messages.length && markerMsgIndex === -1; i++) {
const content = messages[i].content;
for (let j = 0; j < content.length; j++) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If messages[i].content is undefined or null at runtime, accessing content.length will throw a TypeError. We should default it to an empty array to prevent crashes.

Suggested change
for (let i = 0; i < messages.length && markerMsgIndex === -1; i++) {
const content = messages[i].content;
for (let j = 0; j < content.length; j++) {
for (let i = 0; i < messages.length && markerMsgIndex === -1; i++) {
const content = messages[i].content ?? [];
for (let j = 0; j < content.length; j++) {

Comment on lines +399 to +407
const systemIdx = messages.findIndex((m) => m.role === 'system');
if (systemIdx !== -1) {
messages[systemIdx] = {
...messages[systemIdx],
content: [
...messages[systemIdx].content,
{ text: instructions, metadata: { [MARKER_KEY]: true } },
],
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If messages[systemIdx].content is undefined or null at runtime, spreading it will throw a TypeError. We should safely default it to an empty array.

Suggested change
const systemIdx = messages.findIndex((m) => m.role === 'system');
if (systemIdx !== -1) {
messages[systemIdx] = {
...messages[systemIdx],
content: [
...messages[systemIdx].content,
{ text: instructions, metadata: { [MARKER_KEY]: true } },
],
};
const systemIdx = messages.findIndex((m) => m.role === 'system');
if (systemIdx !== -1) {
messages[systemIdx] = {
...messages[systemIdx],
content: [
...(messages[systemIdx].content ?? []),
{ text: instructions, metadata: { [MARKER_KEY]: true } }
],
};

Comment on lines +69 to +71
for await (const chunk of turn.stream) {
for (const part of chunk.raw.modelChunk?.content ?? []) {
if (part.toolRequest) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If chunk.raw is undefined or null at runtime, accessing chunk.raw.modelChunk will throw a TypeError. Using optional chaining chunk.raw?.modelChunk prevents potential crashes during streaming.

        let accumulated = '';
        for await (const chunk of turn.stream) {
          for (const part of chunk.raw?.modelChunk?.content ?? []) {

Add retry middleware with exponential backoff to handle transient model
errors (e.g. rate limits / unavailability) before they reach the user.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation js

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant