blob: 9d5b831a4bbcb3c03938750a7a8fb7f115265b84 [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 m from 'mithril';
import type {LlmGateway} from '../dev.perfetto.Llm/gateway';
import {Agent} from './agent';
import type {ContextRegistry} from './context';
import type {ToolRegistry} from './tools';
import {SYSTEM_PROMPT} from './system_prompt';
/** A single line in the conversation transcript. */
export interface ChatLine {
/** Which kind of line this is; drives how it renders. */
role: 'ai' | 'user' | 'error' | 'thought' | 'toolcall' | 'spacer';
/** The line's text content (e.g. message body, or tool name for toolcalls). */
text: string;
/**
* For 'toolcall' lines: the call args and (once it arrives) the result, so
* the line can show a collapsible summary of what the tool did and returned.
*/
toolArgs?: unknown;
toolResult?: string;
toolError?: boolean;
}
/**
* The state and behaviour of one chat conversation with the agent: owns the
* transcript, the in-flight turn, the follow-up queue, and the Agent instance.
* The UI renders from this and calls its methods; there is no other state.
*/
export class ChatSession {
/**
* The assembled system prompt in use for this conversation - kept so the
* "show system prompt" inspector displays exactly what the agent sends.
*/
readonly systemPrompt: string;
/** The conversation transcript, in display order. */
lines: ChatLine[] = [];
/** The current (uncommitted) text in the input box. */
input = '';
/** True while a turn is in flight. */
loading = false;
/** Total tokens reported by the most recent turn, if any. */
totalTokens?: number;
/** Context items the user has toggled off for the next prompt. */
readonly excludedContext = new Set<string>();
private readonly agent: Agent;
private abort?: AbortController;
// Messages typed while a turn was in flight. The agent polls this at the top
// of each tool-use round; anything still queued when the turn ends starts a
// fresh turn. Cancelling drops the queue.
private pendingFollowUps: string[] = [];
/**
* @param gateway - The LLM gateway used to drive the agent.
* @param tools - The registry of tools available to the agent.
* @param context - The registry supplying per-turn context items and, at
* construction, the payload-format descriptions folded into the system
* prompt.
*/
constructor(
gateway: LlmGateway,
tools: ToolRegistry,
private readonly context: ContextRegistry,
) {
// The context providers' invariant payload-format descriptions are folded
// into the system prompt here, once per conversation - per-turn payloads
// stay data-only.
this.systemPrompt = assembleSystemPrompt(context);
this.agent = new Agent(gateway, tools, this.systemPrompt);
this.lines = [];
}
/**
* Submit the current input. If a turn is already running the text is queued
* as a follow-up; otherwise it starts a new turn (and drains any follow-ups
* queued while it runs). No-op on empty input.
*/
async send(): Promise<void> {
const text = this.input.trim();
if (text === '') return;
this.input = '';
this.lines.push({role: 'user', text});
if (this.loading) {
// Typed mid-turn: queue it as a follow-up. The running turn picks it up
// at its next tool-use round, or the loop below starts a new turn with
// it once the current one finishes.
this.pendingFollowUps.push(text);
return;
}
let next: string | undefined = text;
while (next !== undefined) {
await this.runTurn(next);
next = this.pendingFollowUps.shift();
}
}
/**
* Abort the in-flight turn and drop any queued follow-ups - "stop" means
* stop. Leaves the transcript in place with a "(turn cancelled)" marker.
*/
cancel(): void {
this.abort?.abort();
this.pendingFollowUps = [];
this.lines.push({role: 'error', text: '(turn cancelled)'});
}
/**
* Reset to a fresh conversation: abort any in-flight turn, clear the agent's
* history and the token count, and replace the transcript with a greeting.
*/
newConversation(): void {
this.abort?.abort();
this.pendingFollowUps = [];
this.agent.reset();
this.totalTokens = undefined;
this.lines = [
{role: 'ai', text: 'New conversation. What would you like to know?'},
];
}
private appendAi(text: string) {
const last = this.lines[this.lines.length - 1];
if (last?.role === 'ai') {
last.text += text;
} else {
this.lines.push({role: 'ai', text});
}
}
private async runTurn(text: string): Promise<void> {
// Fold in any non-excluded context as a preamble the model can see.
const context = this.context
.buildContextItems()
.filter((c) => !this.excludedContext.has(c.id));
const prompt =
context.length === 0
? text
: `Context for this question:\n${context
.map((c) => `- ${c.label}: ${c.payload}`)
.join('\n')}\n\nQuestion: ${text}`;
this.loading = true;
this.abort = new AbortController();
m.redraw();
try {
for await (const evt of this.agent.sendMessage(
prompt,
this.abort.signal,
() => this.pendingFollowUps.shift(),
)) {
switch (evt.type) {
case 'text':
this.appendAi(evt.text);
break;
case 'thought':
this.lines.push({role: 'thought', text: evt.text});
break;
case 'toolcall':
this.lines.push({
role: 'toolcall',
text: evt.name,
toolArgs: evt.args,
});
this.lines.push({role: 'spacer', text: ''});
break;
case 'toolresult':
// Attach the result to the most recent matching tool-call line that
// doesn't have one yet (tool calls within a round execute in order).
for (let i = this.lines.length - 1; i >= 0; i--) {
const line = this.lines[i];
if (
line.role === 'toolcall' &&
line.text === evt.name &&
line.toolResult === undefined
) {
line.toolResult = evt.result;
line.toolError = evt.isError;
break;
}
}
break;
case 'usage':
if (evt.totalTokens !== undefined) {
this.totalTokens = evt.totalTokens;
}
break;
case 'error':
this.lines.push({role: 'error', text: evt.message});
break;
}
m.redraw();
}
this.lines.push({role: 'spacer', text: ''});
} catch (e) {
this.lines.push({role: 'error', text: `Something went wrong: ${e}`});
} finally {
this.loading = false;
this.abort = undefined;
m.redraw();
}
}
}
// The assembled system prompt: the application brief plus the context
// providers' payload-format descriptions (already sorted by provider id, so
// the result is byte-identical across turns and the cached prefix survives).
// Sampled once per conversation; providers registered mid-conversation take
// effect on the next one.
function assembleSystemPrompt(context: ContextRegistry): string {
const descriptions = context.descriptions();
if (descriptions.length === 0) return SYSTEM_PROMPT;
return [
SYSTEM_PROMPT,
'Context payload formats (for the context preamble on user messages):',
...descriptions,
].join('\n\n');
}