blob: e5bd63ff2443a0f874d8024b38e447cd19488420 [file] [edit]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {z} from 'zod';
import type {LlmGateway, ModelPath} from '../dev.perfetto.Llm/gateway';
import type {
Message,
ToolCall,
ToolDef,
ToolResult,
} from '../dev.perfetto.Llm/protocol';
import type {ToolRegistry} from './tools';
// Hard bound on tool-use rounds per user turn. Stops a misbehaving model from
// looping forever (and caps token spend). Hitting it surfaces to the user
// rather than failing silently.
const MAX_ITERATIONS = 50;
// What the UI consumes as a turn streams.
export type AgentEvent =
| {readonly type: 'text'; readonly text: string}
| {readonly type: 'thought'; readonly text: string}
| {readonly type: 'toolcall'; readonly name: string; readonly args: unknown}
| {
readonly type: 'toolresult';
readonly name: string;
readonly isError: boolean;
// The (possibly truncated) tool result string, so the UI can show a
// summary of what the tool returned. This is the same string fed back to
// the model.
readonly result: string;
}
| {
readonly type: 'usage';
readonly inputTokens?: number;
readonly outputTokens?: number;
readonly totalTokens?: number;
}
// A user-facing error (backend failure, iteration cap, no model configured).
// Distinct from a tool error, which is fed back to the model, not surfaced.
| {readonly type: 'error'; readonly message: string};
export class Agent {
// The full conversation history (system prompt aside). Owned here because LLM
// endpoints are stateless and we resend it every request.
private history: Message[] = [];
/**
* @param gateway - The LLM gateway used to drive the model.
* @param tools - The registry of tools available to the model.
* @param systemPrompt - The system prompt sent on every request.
* @param eagerTools - When true (the default), every tool definition is sent
* on every request and the list_tools/more_tools meta-tools are omitted.
* The lazy scheme (discover via list_tools, provision via more_tools) is
* an optimisation for *large* tool sets, but the indirection reliably
* trips up smaller / local models - they loop calling more_tools instead
* of the real tool. We only have a handful of core tools, so eager is
* both simpler and more robust. Flip this off once the tool count makes
* eager loading expensive.
*/
constructor(
private readonly gateway: LlmGateway,
private readonly tools: ToolRegistry,
private readonly systemPrompt: string,
private readonly eagerTools: boolean = true,
) {}
reset(): void {
this.history = [];
}
/**
* Run one user turn to completion, yielding events as they stream.
*
* @param userText - The user's message for this turn.
* @param signal - Optional cancellation signal. An aborted turn stops
* cleanly and what completed stays in the history (the transcript stays
* truthful).
* @param dequeueFollowUp - Implements queued follow-ups: it is polled once at
* the top of each tool-use round, and any message it returns is appended
* to the history as a user message, so something the user typed mid-loop
* ("oh wait, also check X") reaches the model at the next round without
* any interruption machinery.
* @yields Events as the turn streams.
*/
async *sendMessage(
userText: string,
signal?: AbortSignal,
dequeueFollowUp?: () => string | undefined,
): AsyncGenerator<AgentEvent, void, void> {
this.history.push({role: 'user', text: userText});
// The model driving this turn: the first configured model advertising the
// 'agentic' role. Looked up per turn so adding/removing a provider between
// turns routes the next turn accordingly (history carries over).
const modelDetails = this.gateway
.listModels()
.find((m) => m.model.roles.includes('agentic'));
if (modelDetails === undefined) {
yield {
type: 'error',
message:
'No agentic model configured. Add a provider and a model with the ' +
'agentic role in the LLM settings.',
};
return;
}
const modelPath: ModelPath = {
providerId: modelDetails.provider.id,
modelId: modelDetails.model.id,
};
// createStream requires a signal; use a never-aborting fallback when the
// caller didn't supply one.
const abortSignal = signal ?? new AbortController().signal;
// The set of tool names exposed to the model this turn. In eager mode it's
// every tool up front; in lazy mode it starts empty and grows as the model
// pulls tools in via more_tools.
const loaded = new Set<string>(
this.eagerTools ? this.tools.list().map((t) => t.name) : [],
);
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
if (signal?.aborted) return;
// Pick up any follow-up the user queued while the loop was running.
const followUp = dequeueFollowUp?.();
if (followUp !== undefined) {
this.history.push({role: 'user', text: followUp});
}
const toolDefs = this.buildToolDefs(loaded);
const calls: ToolCall[] = [];
let turnText = '';
let backendError: string | undefined;
for await (const evt of this.gateway.createStream(
modelPath,
{
systemPrompt: this.systemPrompt,
messages: this.history,
tools: toolDefs,
},
abortSignal,
)) {
switch (evt.type) {
case 'text':
turnText += evt.text;
yield {type: 'text', text: evt.text};
break;
case 'thought':
yield {type: 'thought', text: evt.text};
break;
case 'tool-call':
calls.push(evt.call);
yield {
type: 'toolcall',
name: evt.call.name,
args: evt.call.args,
};
break;
case 'usage':
yield {type: 'usage', ...evt.usage};
break;
case 'stop':
if (evt.reason === 'error') {
backendError = evt.error?.message ?? 'Unknown backend error';
}
break;
}
}
if (signal?.aborted) return;
if (backendError !== undefined) {
yield {type: 'error', message: backendError};
return;
}
// Record the model's turn (text and/or tool calls) in the history.
if (turnText.length > 0) {
this.history.push({role: 'model', text: turnText});
}
if (calls.length === 0) {
// No tool calls -> the model is done with this turn.
return;
}
this.history.push({role: 'tool-call', calls});
// Execute every requested tool, threading results back. Tool errors
// (bad SQL, invalid args) become tool results the model can self-correct
// from - they are NOT surfaced to the user as errors.
const results: ToolResult[] = [];
for (const call of calls) {
if (signal?.aborted) return;
const {result, isError} = await this.runTool(call, loaded);
yield {type: 'toolresult', name: call.name, isError, result};
results.push({name: call.name, result, isError});
}
this.history.push({role: 'tool-result', results});
}
yield {
type: 'error',
message:
`Stopped after ${MAX_ITERATIONS} tool-use rounds without a ` +
`final answer. Try narrowing the question.`,
};
}
// Execute one tool call. The meta-tools (list_tools / more_tools) are handled
// here because they mutate the loop's loaded-tool set; everything else goes
// through the registry (which validates args first).
private async runTool(
call: ToolCall,
loaded: Set<string>,
): Promise<{result: string; isError: boolean}> {
try {
if (call.name === 'list_tools') {
const list = this.tools.list().map((t) => ({
name: t.name,
description: firstLine(t.description),
}));
return {result: JSON.stringify(list), isError: false};
}
if (call.name === 'more_tools') {
const names = z
.object({names: z.array(z.string())})
.parse(call.args).names;
const unknown = names.filter((n) => this.tools.get(n) === undefined);
names.forEach((n) => this.tools.get(n) && loaded.add(n));
const msg =
unknown.length > 0
? `Loaded. Unknown (ignored): ${unknown.join(', ')}.`
: 'Loaded. The requested tools are now available.';
return {result: msg, isError: unknown.length > 0};
}
const result = await this.tools.call(call.name, call.args);
return {result, isError: false};
} catch (e) {
return {result: `Error: ${String(e)}`, isError: true};
}
}
// The tool list sent on a request. In lazy mode this is the bootstrap
// meta-tools (list_tools/more_tools) plus every tool the model has loaded so
// far; in eager mode the meta-tools are omitted and `loaded` already holds
// every tool. Order is stable and the set only grows within a turn,
// preserving the cached prompt prefix.
private buildToolDefs(loaded: Set<string>): ToolDef[] {
const defs: ToolDef[] = [];
if (!this.eagerTools) {
defs.push(
{
name: 'list_tools',
description:
'List the names and one-line descriptions of every tool ' +
'available. Call this first to discover what you can do, then ' +
'more_tools to load the ones you need before calling them.',
inputSchema: z.object({}),
},
{
name: 'more_tools',
description:
'Load tool definitions by name so you can call them. You can only ' +
'call a tool after loading it here. Discover names with list_tools.',
inputSchema: z.object({
names: z
.array(z.string())
.describe('Tool names to load, from list_tools.'),
}),
},
);
}
for (const tool of this.tools.list()) {
if (loaded.has(tool.name)) {
defs.push({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
});
}
}
return defs;
}
}
function firstLine(s: string): string {
const trimmed = s.trim();
const nl = trimmed.indexOf('\n');
return nl === -1 ? trimmed : trimmed.slice(0, nl);
}