feat(js/middleware): add handoff middleware for agent transfer pattern#5619
feat(js/middleware): add handoff middleware for agent transfer pattern#5619pavelgj wants to merge 2 commits into
Conversation
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
There was a problem hiding this comment.
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.
| const baseTools = (request.tools ?? []).filter( | ||
| (t) => !managedToolNames.has(t) | ||
| ); |
There was a problem hiding this comment.
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.
| 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); | |
| }); |
| 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--) { |
There was a problem hiding this comment.
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.
| 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--) { |
| for (let i = 0; i < messages.length && markerMsgIndex === -1; i++) { | ||
| const content = messages[i].content; | ||
| for (let j = 0; j < content.length; j++) { |
There was a problem hiding this comment.
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.
| 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++) { |
| 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 } }, | ||
| ], | ||
| }; |
There was a problem hiding this comment.
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.
| 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 } } | |
| ], | |
| }; |
| for await (const chunk of turn.stream) { | ||
| for (const part of chunk.raw.modelChunk?.content ?? []) { | ||
| if (part.toolRequest) { |
There was a problem hiding this comment.
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.
Introduce a new
handoffmiddleware that enables the agent transfer/handoff pattern, where a host agent hosts multiple specialized personas and transfers control between them via generatedtransfer_to_<persona>tools. The active persona is tracked statelessly from conversation history, swapping system prompts and visible tools across turns.handofffrom the middleware package index