@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
809 lines (705 loc) • 24.2 kB
text/typescript
import type {
Agent,
AgentEvent,
AgentInstruction,
AgentRuntimeContext,
AgentState,
Cost,
InstructionExecutor,
RuntimeConfig,
ToolRegistry,
ToolsCalling,
Usage,
} from '../types';
/**
* Simplified Agent Runtime - The "Engine" that executes instructions from an "Agent" (Brain).
* Now includes built-in call_llm support and allows full executor customization.
*/
export class AgentRuntime {
private executors: Record<AgentInstruction['type'], InstructionExecutor>;
constructor(
private agent: Agent,
private config: RuntimeConfig = {},
) {
// Build executors with priority: agent.executors > config.executors > built-in
this.executors = {
call_llm: this.createCallLLMExecutor(),
call_tool: this.createCallToolExecutor(),
finish: this.createFinishExecutor(),
request_human_approve: this.createHumanApproveExecutor(),
request_human_prompt: this.createHumanPromptExecutor(),
request_human_select: this.createHumanSelectExecutor(),
// Config executors override built-in
...config.executors,
// Agent provided executors have highest priority
...(agent.executors as any),
};
}
/**
* Executes a single step of the Plan -> Execute loop.
* @param state - Current agent state
* @param context - Runtime context for this step (required for proper phase detection)
*/
async step(
state: AgentState,
context?: AgentRuntimeContext,
): Promise<{ events: AgentEvent[]; newState: AgentState; nextContext?: AgentRuntimeContext }> {
try {
// Increment step count and check limits
const newState = structuredClone(state);
newState.stepCount += 1;
newState.lastModified = new Date().toISOString();
// Check maximum steps limit
if (newState.maxSteps && newState.stepCount > newState.maxSteps) {
// Finish execution when maxSteps is exceeded
newState.status = 'done';
const finishEvent = {
finalState: newState,
reason: 'max_steps_exceeded' as const,
reasonDetail: `Maximum steps exceeded: ${newState.maxSteps}`,
type: 'done' as const,
};
return {
events: [finishEvent],
newState,
nextContext: undefined, // No next context when done
};
}
// Use provided context or create initial context
const runtimeContext = context || this.createInitialContext(newState);
// Get instructions from agent runner and normalize to array
let rawInstructions: any;
// Handle human approved tool calls
if (runtimeContext.phase === 'human_approved_tool') {
const approvedPayload = runtimeContext.payload as { approvedToolCall: ToolsCalling };
rawInstructions = { payload: approvedPayload.approvedToolCall, type: 'call_tool' };
} else {
// Standard flow: Plan -> Execute
rawInstructions = await this.agent.runner(runtimeContext, newState);
}
// Normalize to array
const instructions = Array.isArray(rawInstructions) ? rawInstructions : [rawInstructions];
// Execute all instructions sequentially
let currentState = newState;
const allEvents: AgentEvent[] = [];
let finalNextContext: AgentRuntimeContext | undefined = undefined;
for (const instruction of instructions) {
let result;
// Special handling for batch tool execution
if (instruction.type === 'call_tools_batch') {
result = await this.executeToolsBatch(instruction as any, currentState);
} else {
const executor = this.executors[instruction.type as keyof typeof this.executors];
if (!executor) {
throw new Error(`No executor found for instruction type: ${instruction.type}`);
}
result = await executor(instruction, currentState);
}
// Accumulate events
allEvents.push(...result.events);
// Update state
currentState = result.newState;
// Keep the last nextContext
if (result.nextContext) {
finalNextContext = result.nextContext;
}
// Stop execution if blocked
if (currentState.status === 'waiting_for_human' || currentState.status === 'interrupted') {
break;
}
}
// Ensure stepCount and lastModified are preserved
currentState.stepCount = newState.stepCount;
currentState.lastModified = newState.lastModified;
return {
events: allEvents,
newState: currentState,
nextContext: finalNextContext,
};
} catch (error) {
const errorState = structuredClone(state);
errorState.stepCount += 1;
errorState.lastModified = new Date().toISOString();
return this.createErrorResult(errorState, error);
}
}
/**
* Convenience method for approving and executing a tool call
*/
async approveToolCall(
state: AgentState,
approvedToolCall: any,
): Promise<{ events: AgentEvent[]; newState: AgentState; nextContext?: AgentRuntimeContext }> {
const context: AgentRuntimeContext = {
payload: { approvedToolCall },
phase: 'human_approved_tool',
session: this.createSessionContext(state),
};
return this.step(state, context);
}
/**
* Interrupt the current execution
* @param state - Current agent state
* @param reason - Reason for interruption
* @param canResume - Whether the interruption can be resumed later
* @param metadata - Additional metadata about the interruption
*/
interrupt(
state: AgentState,
reason: string,
canResume: boolean = true,
metadata?: Record<string, unknown>,
): { events: AgentEvent[]; newState: AgentState } {
const newState = structuredClone(state);
const interruptedAt = new Date().toISOString();
newState.status = 'interrupted';
newState.lastModified = interruptedAt;
newState.interruption = {
canResume,
interruptedAt,
// Store the current step for potential resumption
interruptedInstruction: undefined,
reason, // Could be enhanced to store current instruction
};
const interruptEvent: AgentEvent = {
canResume,
interruptedAt,
metadata,
reason,
type: 'interrupted',
};
return {
events: [interruptEvent],
newState,
};
}
/**
* Resume execution from an interrupted state
* @param state - Interrupted agent state
* @param reason - Reason for resumption
* @param context - Optional context to resume with
*/
async resume(
state: AgentState,
reason: string = 'User resumed execution',
context?: AgentRuntimeContext,
): Promise<{ events: AgentEvent[]; newState: AgentState; nextContext?: AgentRuntimeContext }> {
if (state.status !== 'interrupted') {
throw new Error('Cannot resume: state is not interrupted');
}
if (state.interruption && !state.interruption.canResume) {
throw new Error('Cannot resume: interruption is not resumable');
}
const newState = structuredClone(state);
const resumedAt = new Date().toISOString();
const resumedFromStep = state.stepCount;
// Clear interruption context and set status back to running
newState.status = 'running';
newState.lastModified = resumedAt;
newState.interruption = undefined;
const resumeEvent: AgentEvent = {
reason,
resumedAt,
resumedFromStep,
type: 'resumed',
};
// If context is provided, continue with that context
if (context) {
const result = await this.step(newState, context);
return {
events: [resumeEvent, ...result.events],
newState: result.newState,
nextContext: result.nextContext,
};
}
// Otherwise, just return the resumed state
return {
events: [resumeEvent],
newState,
nextContext: this.createInitialContext(newState),
};
}
/**
* Create default usage statistics structure
* @returns Default Usage object with all counters set to 0
*/
static createDefaultUsage(): Usage {
return {
humanInteraction: {
approvalRequests: 0,
promptRequests: 0,
selectRequests: 0,
totalWaitingTimeMs: 0,
},
llm: {
apiCalls: 0,
processingTimeMs: 0,
tokens: { input: 0, output: 0, total: 0 },
},
tools: {
byTool: [],
totalCalls: 0,
totalTimeMs: 0,
},
};
}
/**
* Create default cost structure
* @returns Default Cost object with all costs set to 0
*/
static createDefaultCost(): Cost {
const now = new Date().toISOString();
return {
calculatedAt: now,
currency: 'USD',
llm: {
byModel: [],
currency: 'USD',
total: 0,
},
tools: {
byTool: [],
currency: 'USD',
total: 0,
},
total: 0,
};
}
/**
* Create a new agent state with flexible initialization
* @param partialState - Partial state to override defaults
* @returns Complete AgentState with defaults filled in
*/
static createInitialState(
partialState?: Partial<AgentState> & { sessionId: string },
): AgentState {
const now = new Date().toISOString();
return {
cost: AgentRuntime.createDefaultCost(),
// Default values
createdAt: now,
lastModified: now,
messages: [],
status: 'idle',
stepCount: 0,
toolManifestMap: {},
usage: AgentRuntime.createDefaultUsage(),
// User provided values override defaults
...(partialState || { sessionId: '' }),
};
}
// ============ Executor Factory Methods ============
/** Create call_llm executor with streaming support */
private createCallLLMExecutor(): InstructionExecutor {
return async (instruction, state) => {
const { payload } = instruction as Extract<AgentInstruction, { type: 'call_llm' }>;
const newState = structuredClone(state);
const events: AgentEvent[] = [];
newState.status = 'running';
newState.lastModified = new Date().toISOString();
events.push({ payload, type: 'llm_start' });
// Use Agent's modelRuntime first, fallback to config
const modelRuntime = this.agent.modelRuntime;
if (!modelRuntime) {
throw new Error(
'Model Runtime is required for call_llm instruction. Provide it via Agent.modelRuntime or RuntimeConfig.modelRuntime',
);
}
let assistantContent = '';
let toolCalls: ToolsCalling[] = [];
try {
// Stream LLM response
for await (const chunk of modelRuntime(payload)) {
// Emit individual stream events for each chunk
events.push({ chunk, type: 'llm_stream' });
// Accumulate content and tool calls from chunks
if (chunk.content) {
assistantContent += chunk.content;
}
if (chunk.tool_calls) {
toolCalls = chunk.tool_calls;
}
}
events.push({
result: { content: assistantContent, tool_calls: toolCalls },
type: 'llm_result',
});
// Update usage and cost if agent provides calculation methods
if (this.agent.calculateUsage) {
newState.usage = this.agent.calculateUsage(
'llm',
{ content: assistantContent, tool_calls: toolCalls },
newState.usage,
);
}
if (this.agent.calculateCost) {
newState.cost = this.agent.calculateCost({
costLimit: newState.costLimit,
previousCost: newState.cost,
usage: newState.usage,
});
}
// Check cost limits
if (newState.costLimit && newState.cost.total > newState.costLimit.maxTotalCost) {
return this.handleCostLimitExceeded(newState);
}
// Provide next context based on LLM result
const nextContext: AgentRuntimeContext = {
payload: {
hasToolCalls: toolCalls.length > 0,
result: { content: assistantContent, tool_calls: toolCalls },
toolCalls,
},
phase: 'llm_result',
session: this.createSessionContext(newState),
};
return { events, newState, nextContext };
} catch (error) {
return this.createErrorResult(state, error);
}
};
}
/** Create call_tool executor */
private createCallToolExecutor(): InstructionExecutor {
return async (instruction, state) => {
const { payload: toolCall } = instruction as Extract<AgentInstruction, { type: 'call_tool' }>;
const newState = structuredClone(state);
const events: AgentEvent[] = [];
newState.lastModified = new Date().toISOString();
newState.status = 'running';
const tools = this.agent.tools || ({} as ToolRegistry);
// Support both ToolsCalling (OpenAI format) and CallingToolPayload formats
const toolName = toolCall.apiName || toolCall.function?.name;
const toolArgs = toolCall.arguments || toolCall.function?.arguments;
const toolId = toolCall.id;
const handler = tools[toolName];
if (!handler) throw new Error(`Tool not found: ${toolName}`);
const args = JSON.parse(toolArgs);
const result = await handler(args);
newState.messages.push({
content: JSON.stringify(result),
role: 'tool',
tool_call_id: toolId,
});
events.push({ id: toolId, result, type: 'tool_result' });
// Update usage and cost if agent provides calculation methods
if (this.agent.calculateUsage) {
newState.usage = this.agent.calculateUsage(
'tool',
{ executionTime: 0, result, toolCall }, // Could track actual execution time
newState.usage,
);
}
if (this.agent.calculateCost) {
newState.cost = this.agent.calculateCost({
costLimit: newState.costLimit,
previousCost: newState.cost,
usage: newState.usage,
});
}
// Check cost limits
if (newState.costLimit && newState.cost.total > newState.costLimit.maxTotalCost) {
return this.handleCostLimitExceeded(newState);
}
// Provide next context for tool result
const nextContext: AgentRuntimeContext = {
payload: {
result,
toolCall,
toolCallId: toolCall.id,
},
phase: 'tool_result',
session: this.createSessionContext(newState),
};
return { events, newState, nextContext };
};
}
/** Create human approve executor */
private createHumanApproveExecutor(): InstructionExecutor {
return async (instruction, state) => {
const { pendingToolsCalling } = instruction as Extract<
AgentInstruction,
{ type: 'request_human_approve' }
>;
const newState = structuredClone(state);
newState.lastModified = new Date().toISOString();
newState.status = 'waiting_for_human';
newState.pendingToolsCalling = pendingToolsCalling;
const events: AgentEvent[] = [
{
pendingToolsCalling,
sessionId: newState.sessionId,
type: 'human_approve_required',
},
{ toolCalls: pendingToolsCalling, type: 'tool_pending' },
];
return { events, newState };
};
}
/** Create human prompt executor */
private createHumanPromptExecutor(): InstructionExecutor {
return async (instruction, state) => {
const { metadata, prompt } = instruction as Extract<
AgentInstruction,
{ type: 'request_human_prompt' }
>;
const newState = structuredClone(state);
newState.lastModified = new Date().toISOString();
newState.status = 'waiting_for_human';
newState.pendingHumanPrompt = { metadata, prompt };
const events: AgentEvent[] = [
{
metadata,
prompt,
sessionId: newState.sessionId,
type: 'human_prompt_required',
},
];
return { events, newState };
};
}
/** Create human select executor */
private createHumanSelectExecutor(): InstructionExecutor {
return async (instruction, state) => {
const { metadata, multi, options, prompt } = instruction as Extract<
AgentInstruction,
{ type: 'request_human_select' }
>;
const newState = structuredClone(state);
newState.lastModified = new Date().toISOString();
newState.status = 'waiting_for_human';
newState.pendingHumanSelect = { metadata, multi, options, prompt };
const events: AgentEvent[] = [
{
metadata,
multi,
options,
prompt,
sessionId: newState.sessionId,
type: 'human_select_required',
},
];
return { events, newState };
};
}
/** Create finish executor */
private createFinishExecutor(): InstructionExecutor {
return async (instruction, state) => {
const { reason, reasonDetail } = instruction as Extract<AgentInstruction, { type: 'finish' }>;
const newState = structuredClone(state);
newState.lastModified = new Date().toISOString();
newState.status = 'done';
const events: AgentEvent[] = [
{
finalState: newState,
reason,
reasonDetail,
type: 'done',
},
];
return { events, newState };
};
}
// ============ Helper Methods ============
/**
* Execute multiple tool calls concurrently
*/
private async executeToolsBatch(
instruction: { payload: any[]; type: 'call_tools_batch' },
baseState: AgentState,
): Promise<{
events: AgentEvent[];
newState: AgentState;
nextContext?: AgentRuntimeContext;
}> {
const { payload: toolsCalling } = instruction;
// Execute all tools concurrently based on the same state
const results = await Promise.all(
toolsCalling.map((toolCall) =>
this.executors.call_tool(
{ payload: toolCall, type: 'call_tool' } as any,
structuredClone(baseState), // Each tool starts from the same base state
),
),
);
// Merge results
return this.mergeToolResults(results, baseState);
}
/**
* Merge multiple tool execution results
*/
private mergeToolResults(
results: Array<{
events: AgentEvent[];
newState: AgentState;
nextContext?: AgentRuntimeContext;
}>,
baseState: AgentState,
): {
events: AgentEvent[];
newState: AgentState;
nextContext?: AgentRuntimeContext;
} {
const newState = structuredClone(baseState);
const allEvents: AgentEvent[] = [];
// Merge all tool messages in order
for (const result of results) {
// Extract tool role messages
const toolMessages = result.newState.messages.filter((m) => m.role === 'tool');
newState.messages.push(...toolMessages);
// Merge events
allEvents.push(...result.events);
// Merge usage statistics (if available)
if (result.newState.usage && newState.usage) {
newState.usage.tools.totalCalls += result.newState.usage.tools.totalCalls;
newState.usage.tools.totalTimeMs += result.newState.usage.tools.totalTimeMs;
// Merge per-tool statistics (now using array)
result.newState.usage.tools.byTool.forEach((toolStats) => {
const existingTool = newState.usage.tools.byTool.find((t) => t.name === toolStats.name);
if (existingTool) {
existingTool.calls += toolStats.calls;
existingTool.totalTimeMs += toolStats.totalTimeMs;
existingTool.errors += toolStats.errors || 0;
} else {
newState.usage.tools.byTool.push({ ...toolStats });
}
});
}
// Merge cost statistics (if available)
if (result.newState.cost && newState.cost) {
newState.cost.tools.total += result.newState.cost.tools.total;
newState.cost.total += result.newState.cost.tools.total;
// Merge per-tool cost statistics (now using array)
result.newState.cost.tools.byTool.forEach((toolCost) => {
const existingToolCost = newState.cost.tools.byTool.find((t) => t.name === toolCost.name);
if (existingToolCost) {
existingToolCost.calls += toolCost.calls;
existingToolCost.totalCost += toolCost.totalCost;
} else {
newState.cost.tools.byTool.push({ ...toolCost });
}
});
}
}
newState.lastModified = new Date().toISOString();
return {
events: allEvents,
newState,
nextContext: {
payload: {
toolCount: results.length,
toolResults: results.map((r) => r.nextContext?.payload),
},
phase: 'tools_batch_result',
session: this.createSessionContext(newState),
},
};
}
/**
* Handle cost limit exceeded scenario
*/
private handleCostLimitExceeded(state: AgentState): {
events: AgentEvent[];
newState: AgentState;
nextContext?: AgentRuntimeContext;
} {
const newState = structuredClone(state);
const costLimit = newState.costLimit!;
switch (costLimit.onExceeded) {
case 'stop': {
newState.status = 'done';
const finishEvent = {
finalState: newState,
reason: 'cost_limit_exceeded' as const,
reasonDetail: `Cost limit exceeded: ${newState.cost.total} ${newState.cost.currency} > ${costLimit.maxTotalCost} ${costLimit.currency}`,
type: 'done' as const,
};
return {
events: [finishEvent],
newState,
nextContext: undefined,
};
}
case 'interrupt': {
return {
...this.interrupt(
newState,
`Cost limit exceeded: ${newState.cost.total} ${newState.cost.currency}`,
true,
{
costExceeded: true,
currentCost: newState.cost.total,
limitCost: costLimit.maxTotalCost,
},
),
nextContext: undefined,
};
}
default: {
// Continue execution but emit warning event
const warningEvent = {
error: new Error(
`Warning: Cost limit exceeded: ${newState.cost.total} ${newState.cost.currency}`,
),
type: 'error' as const,
};
return {
events: [warningEvent],
newState,
nextContext: {
payload: { error: warningEvent.error, isCostWarning: true },
phase: 'error' as const,
session: this.createSessionContext(newState),
},
};
}
}
}
/**
* Create session context metadata - reusable helper
*/
private createSessionContext(state: AgentState) {
return {
messageCount: state.messages.length,
sessionId: state.sessionId,
status: state.status,
stepCount: state.stepCount,
};
}
/**
* Create initial context for the first step (fallback for backward compatibility)
*/
private createInitialContext(state: AgentState): AgentRuntimeContext {
const lastMessage = state.messages.at(-1);
if (lastMessage?.role === 'user') {
return {
payload: {
isFirstMessage: state.messages.length === 1,
message: lastMessage,
},
phase: 'user_input',
session: this.createSessionContext(state),
};
}
return {
payload: undefined,
phase: 'init',
session: this.createSessionContext(state),
};
}
/** Create error state and events */
private createErrorResult(
state: AgentState,
error: any,
): { events: AgentEvent[]; newState: AgentState } {
const errorState = structuredClone(state);
errorState.status = 'error';
errorState.error = error;
errorState.lastModified = new Date().toISOString();
const errorEvent = { error, type: 'error' } as const;
return {
events: [errorEvent],
newState: errorState,
};
}
}