Skip to content

Commit

Permalink
[bbrt] Model can issue followup tool calls (#4132)
Browse files Browse the repository at this point in the history
The model is now free to automatically send follow up tool calls after
the first one completed (currently up to 5).
  • Loading branch information
aomarks authored Jan 15, 2025
1 parent b18aa5e commit a4e4258
Showing 1 changed file with 30 additions and 21 deletions.
51 changes: 30 additions & 21 deletions packages/bbrt/src/llm/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import type { JsonSerializableObject } from "../util/json-serializable.js";
import { coercePresentableError } from "../util/presentable-error.js";
import type { Result } from "../util/result.js";

const MAX_TOOL_ITERATIONS = 5;

export interface ConversationOptions {
state: ReactiveSessionState;
drivers: Map<string, BBRTDriver>;
Expand Down Expand Up @@ -99,25 +101,22 @@ export class Conversation {

const done = (async (): Promise<void> => {
const activeTools = await this.#getActiveTools();
const { functionCalls } = await this.#callModel(
driver.value,
activeTools,
initialTimestamp,
systemPrompt
);
if (functionCalls.length > 0) {
await Promise.all(
functionCalls.map((call) =>
this.#executeFunctionCall(call, activeTools)
let remainingModelCalls = 1 + Math.max(0, MAX_TOOL_ITERATIONS);
let functionCalls: ReactiveFunctionCallState[];
do {
const allowFunctionCalls = remainingModelCalls > 1;
functionCalls = (
await this.#callModel(
driver.value,
allowFunctionCalls ? activeTools : undefined,
systemPrompt
)
);
await this.#callModel(
driver.value,
undefined,
this.#clock.now(),
systemPrompt
);
}
).functionCalls;
if (functionCalls.length > 0) {
await this.#executeFunctionCalls(functionCalls, activeTools);
}
remainingModelCalls--;
} while (functionCalls.length > 0);
this.#status = "ready";
})();

Expand Down Expand Up @@ -162,9 +161,9 @@ export class Conversation {
async #callModel(
driver: BBRTDriver,
tools: Map<string, BBRTTool> | undefined,
timestamp: number,
systemPrompt: string
): Promise<{ functionCalls: ReactiveFunctionCallState[] }> {
const timestamp = this.#clock.now();
// TODO(aomarks) This is a little weird. The natural thing to do would seem
// to be to create a ReactiveSessionEventTurn, and pass it in. But in fact
// our State constructors treat all initializer data as pure data, so that
Expand All @@ -187,7 +186,8 @@ export class Conversation {
this.state.events.push(event);
const turn = (event.detail as ReactiveSessionEventTurn).turn;
const functionCalls = [];
// Don't include the pending turn we just created.
// Don't include the pending turn we just created. We want the user to see
// it, but not the model.
const slice = this.state.turns.slice(0, -1);
try {
const chunks = driver.send({
Expand All @@ -197,7 +197,7 @@ export class Conversation {
});
for await (const chunk of chunks) {
if (chunk.kind === "function-call") {
if (tools !== undefined) {
if (tools?.size) {
const call = new ReactiveFunctionCallState(chunk.call);
functionCalls.push(call);
turn.chunks.push({
Expand Down Expand Up @@ -227,6 +227,15 @@ export class Conversation {
return { functionCalls };
}

#executeFunctionCalls(
calls: ReactiveFunctionCallState[],
tools: Map<string, BBRTTool>
): Promise<void[]> {
return Promise.all(
calls.map((call) => this.#executeFunctionCall(call, tools))
);
}

async #executeFunctionCall(
call: ReactiveFunctionCallState,
tools: Map<string, BBRTTool>
Expand Down

0 comments on commit a4e4258

Please sign in to comment.