Skip to content

Commit 0f5417c

Browse files
committed
feat(ai): support chat function auto-calling
1 parent 2b2bbd3 commit 0f5417c

4 files changed

Lines changed: 248 additions & 100 deletions

File tree

packages/ai/__tests__/generative-model.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,100 @@ describe('GenerativeModel', () => {
573573
makeRequestStub.mockRestore();
574574
});
575575

576+
it('automatically calls functionReference from chat.sendMessage function calls', async () => {
577+
const getWeather = jest.fn<(args: object) => object>().mockReturnValue({ temperature: 72 });
578+
const genModel = new GenerativeModel(fakeAI, {
579+
model: 'my-model',
580+
tools: [
581+
{
582+
functionDeclarations: [
583+
{
584+
name: 'getWeather',
585+
description: 'Gets weather for a city.',
586+
functionReference: getWeather,
587+
},
588+
],
589+
},
590+
],
591+
});
592+
const makeRequestStub = jest
593+
.spyOn(request, 'makeRequest')
594+
.mockResolvedValueOnce(
595+
responseFromJson({
596+
candidates: [
597+
{
598+
index: 0,
599+
content: {
600+
role: 'model',
601+
parts: [
602+
{
603+
functionCall: {
604+
name: 'getWeather',
605+
args: { city: 'London' },
606+
},
607+
},
608+
],
609+
},
610+
},
611+
],
612+
}),
613+
)
614+
.mockResolvedValueOnce(
615+
responseFromJson({
616+
candidates: [
617+
{
618+
index: 0,
619+
content: {
620+
role: 'model',
621+
parts: [{ text: 'It is 72 degrees.' }],
622+
},
623+
},
624+
],
625+
}),
626+
);
627+
const chat = genModel.startChat();
628+
629+
const result = await chat.sendMessage('weather in London');
630+
const history = await chat.getHistory();
631+
632+
expect(result.response.text()).toBe('It is 72 degrees.');
633+
expect(getWeather).toHaveBeenCalledWith({ city: 'London' });
634+
expect(makeRequestStub).toHaveBeenCalledTimes(2);
635+
expect(history).toEqual([
636+
{
637+
role: 'user',
638+
parts: [{ text: 'weather in London' }],
639+
},
640+
{
641+
role: 'model',
642+
parts: [
643+
{
644+
functionCall: {
645+
name: 'getWeather',
646+
args: { city: 'London' },
647+
},
648+
},
649+
],
650+
},
651+
{
652+
role: 'function',
653+
parts: [
654+
{
655+
functionResponse: {
656+
name: 'getWeather',
657+
response: { temperature: 72 },
658+
},
659+
},
660+
],
661+
},
662+
{
663+
role: 'model',
664+
parts: [{ text: 'It is 72 degrees.' }],
665+
},
666+
]);
667+
makeRequestStub.mockRestore();
668+
});
669+
576670
it('passes CodeExecutionTool through to chat.sendMessage', async function () {
577671
const genModel = new GenerativeModel(fakeAI, {
578672
model: 'my-model',
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import {
19+
Content,
20+
FunctionCall,
21+
FunctionDeclaration,
22+
FunctionResponse,
23+
GenerateContentRequest,
24+
GenerateContentResult,
25+
SingleRequestOptions,
26+
Tool,
27+
} from '../types';
28+
import { ApiSettings } from '../types/internal';
29+
import { generateContent } from './generate-content';
30+
31+
const DEFAULT_MAX_SEQUENTIAL_FUNCTION_CALLS = 10;
32+
33+
export interface AutomaticFunctionCallingResult {
34+
result: GenerateContentResult;
35+
addedContents: Content[];
36+
}
37+
38+
export async function generateContentWithAutomaticFunctionCalling(
39+
apiSettings: ApiSettings,
40+
model: string,
41+
params: GenerateContentRequest,
42+
result: GenerateContentResult,
43+
requestOptions?: SingleRequestOptions,
44+
): Promise<AutomaticFunctionCallingResult> {
45+
let remainingFunctionCalls =
46+
requestOptions?.maxSequentialFunctionCalls ?? DEFAULT_MAX_SEQUENTIAL_FUNCTION_CALLS;
47+
let currentParams = params;
48+
let currentResult = result;
49+
const addedContents: Content[] = [];
50+
51+
while (remainingFunctionCalls > 0) {
52+
const functionCalls = currentResult.response.functionCalls?.();
53+
if (!functionCalls?.length) {
54+
return { result: currentResult, addedContents };
55+
}
56+
57+
const functionResponses = await callFunctionReferences(currentParams.tools, functionCalls);
58+
if (!functionResponses) {
59+
return { result: currentResult, addedContents };
60+
}
61+
62+
const responseContent = getModelResponseContent(currentResult);
63+
if (!responseContent) {
64+
return { result: currentResult, addedContents };
65+
}
66+
67+
remainingFunctionCalls -= 1;
68+
const functionResponseContent: Content = {
69+
role: 'function',
70+
parts: functionResponses.map(functionResponse => ({ functionResponse })),
71+
};
72+
addedContents.push(responseContent, functionResponseContent);
73+
currentParams = {
74+
...currentParams,
75+
contents: [...currentParams.contents, responseContent, functionResponseContent],
76+
};
77+
currentResult = await generateContent(apiSettings, model, currentParams, requestOptions);
78+
}
79+
80+
return { result: currentResult, addedContents };
81+
}
82+
83+
function getModelResponseContent(result: GenerateContentResult): Content | undefined {
84+
const responseContent = result.response.candidates?.[0]?.content;
85+
if (!responseContent) {
86+
return undefined;
87+
}
88+
return {
89+
parts: responseContent.parts || [],
90+
role: responseContent.role || 'model',
91+
};
92+
}
93+
94+
async function callFunctionReferences(
95+
tools: Tool[] | undefined,
96+
functionCalls: FunctionCall[],
97+
): Promise<FunctionResponse[] | undefined> {
98+
const declarations = getFunctionDeclarationsWithReferences(tools);
99+
if (!declarations.length) {
100+
return undefined;
101+
}
102+
103+
const functionResponses: FunctionResponse[] = [];
104+
for (const functionCall of functionCalls) {
105+
const declaration = declarations.find(candidate => candidate.name === functionCall.name);
106+
if (!declaration?.functionReference) {
107+
return undefined;
108+
}
109+
110+
const response = (await declaration.functionReference(functionCall.args)) as object;
111+
functionResponses.push({
112+
id: functionCall.id,
113+
name: functionCall.name,
114+
response,
115+
});
116+
}
117+
return functionResponses;
118+
}
119+
120+
function getFunctionDeclarationsWithReferences(tools: Tool[] | undefined): FunctionDeclaration[] {
121+
return (
122+
tools?.flatMap(tool =>
123+
'functionDeclarations' in tool
124+
? (tool.functionDeclarations?.filter(declaration => declaration.functionReference) ?? [])
125+
: [],
126+
) ?? []
127+
);
128+
}

packages/ai/lib/methods/chat-session.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
templateGenerateContent,
3737
templateGenerateContentStream,
3838
} from './generate-content';
39+
import { generateContentWithAutomaticFunctionCalling } from './automatic-function-calling';
3940
import { ApiSettings } from '../types/internal';
4041
import { logger } from '../logger';
4142
import { mergeRequestOptions } from '../requests/request-options';
@@ -116,17 +117,26 @@ export class ChatSession extends ChatSessionBase<StartChatParams> {
116117
let finalResult = {} as GenerateContentResult;
117118
// Add onto the chain.
118119
this._sendPromise = this._sendPromise
119-
.then(() =>
120-
generateContent(
120+
.then(async () => {
121+
const requestOptions = mergeRequestOptions(this.requestOptions, singleRequestOptions);
122+
const result = await generateContent(
121123
this._apiSettings,
122124
this.model,
123125
generateContentRequest,
124-
mergeRequestOptions(this.requestOptions, singleRequestOptions),
125-
),
126-
)
127-
.then((result: GenerateContentResult) => {
126+
requestOptions,
127+
);
128+
return generateContentWithAutomaticFunctionCalling(
129+
this._apiSettings,
130+
this.model,
131+
generateContentRequest,
132+
result,
133+
requestOptions,
134+
);
135+
})
136+
.then(({ result, addedContents }) => {
128137
if (result.response.candidates && result.response.candidates.length > 0) {
129138
this._history.push(newContent);
139+
this._history.push(...addedContents);
130140
const responseContent: Content = {
131141
parts: result.response.candidates?.[0]?.content.parts || [],
132142
// Response seems to come back without a role set.

0 commit comments

Comments
 (0)