🔨 chore: add agent-runtime (#9206)

* add agent runtime

* add agent runtime

* support finish reason

* update workflow

* 支持中断

* 支持 token usage 统计

* refactor

* add example

* add docs

* update
This commit is contained in:
Arvin Xu
2025-09-14 17:29:19 +08:00
committed by GitHub
parent a47ec04f20
commit d942a635b3
16 changed files with 2544 additions and 2 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
---
description: 包含添加 debug 日志请求时
globs:
description: 包含添加 console.log 日志请求时
globs:
alwaysApply: false
---
# Debug 包使用指南
+1
View File
@@ -19,6 +19,7 @@ jobs:
- electron-server-ipc
- utils
- context-engine
- agent-runtime
name: Test package ${{ matrix.package }}
+1
View File
@@ -143,6 +143,7 @@
"@icons-pack/react-simple-icons": "9.6.0",
"@khmyznikov/pwa-install": "0.3.9",
"@langchain/community": "^0.3.55",
"@lobechat/agent-runtime": "workspace:*",
"@lobechat/const": "workspace:*",
"@lobechat/context-engine": "workspace:*",
"@lobechat/database": "workspace:*",
@@ -0,0 +1,303 @@
// @ts-nocheck
import OpenAI from 'openai';
import { AgentRuntime } from '../src';
import type { Agent, AgentState, RuntimeContext } from '../src';
// OpenAI 模型运行时
async function* openaiRuntime(payload: any) {
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY || '',
});
const { messages, tools } = payload;
const stream = await openai.chat.completions.create({
messages,
model: 'gpt-4.1-mini',
stream: true,
tools,
});
let content = '';
let toolCalls: any[] = [];
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (delta?.content) {
content += delta.content;
yield { content: delta.content };
}
if (delta?.tool_calls) {
for (const toolCall of delta.tool_calls) {
if (!toolCalls[toolCall.index]) {
toolCalls[toolCall.index] = {
function: { arguments: '', name: '' },
id: toolCall.id,
type: 'function',
};
}
if (toolCall.function?.name) {
toolCalls[toolCall.index].function.name += toolCall.function.name;
}
if (toolCall.function?.arguments) {
toolCalls[toolCall.index].function.arguments += toolCall.function.arguments;
}
}
}
}
if (toolCalls.length > 0) {
yield { tool_calls: toolCalls.filter(Boolean) };
}
}
// 简单的 Agent 实现
class SimpleAgent implements Agent {
private conversationState: 'waiting_user' | 'processing_llm' | 'executing_tools' | 'done' =
'waiting_user';
private pendingToolCalls: any[] = [];
// Agent 拥有自己的模型运行时
modelRuntime = openaiRuntime;
// 定义可用工具
tools = {
calculate: async ({ expression }: { expression: string }) => {
try {
// 注意:实际应用中应使用安全的数学解析器
const result = new Function(`"use strict"; return (${expression})`)();
return { expression, result };
} catch {
return { error: 'Invalid expression', expression };
}
},
get_time: async () => {
return {
current_time: new Date().toISOString(),
formatted_time: new Date().toLocaleString(),
};
},
};
// 获取工具定义
private getToolDefinitions() {
return [
{
function: {
description: 'Get current date and time',
name: 'get_time',
parameters: { properties: {}, type: 'object' },
},
type: 'function' as const,
},
{
function: {
description: 'Calculate mathematical expressions',
name: 'calculate',
parameters: {
properties: {
expression: { description: 'Math expression', type: 'string' },
},
required: ['expression'],
type: 'object',
},
},
type: 'function' as const,
},
];
}
// Agent 决策逻辑 - 基于执行阶段和上下文
async runner(context: RuntimeContext, state: AgentState) {
console.log(`[${context.phase}] 对话状态: ${this.conversationState}`);
switch (context.phase) {
case 'init': {
// 初始化阶段
this.conversationState = 'waiting_user';
return { reason: 'No action needed', type: 'finish' as const };
}
case 'user_input': {
// 用户输入阶段
const userPayload = context.payload as { isFirstMessage: boolean; message: any };
console.log(`👤 用户消息: ${userPayload.message.content}`);
// 只有在等待用户输入状态时才处理
if (this.conversationState === 'waiting_user') {
this.conversationState = 'processing_llm';
return {
payload: {
messages: state.messages,
tools: this.getToolDefinitions(),
},
type: 'call_llm' as const,
};
}
// 其他状态下不处理用户输入,结束对话
console.log(`⚠️ 忽略用户输入,当前状态: ${this.conversationState}`);
return {
reason: `Not in waiting_user state: ${this.conversationState}`,
type: 'finish' as const,
};
}
case 'llm_result': {
// LLM 结果阶段,检查是否需要工具调用
const llmPayload = context.payload as { hasToolCalls: boolean; result: any };
// 手动添加 assistant 消息到状态中(修复 Runtime 的问题)
const assistantMessage: any = {
content: llmPayload.result.content || null,
role: 'assistant',
};
if (llmPayload.hasToolCalls) {
const toolCalls = llmPayload.result.tool_calls;
assistantMessage.tool_calls = toolCalls;
this.pendingToolCalls = toolCalls;
this.conversationState = 'executing_tools';
console.log(
'🔧 需要执行工具:',
toolCalls.map((call: any) => call.function.name),
);
// 添加包含 tool_calls 的 assistant 消息
state.messages.push(assistantMessage);
// 执行第一个工具调用
return {
toolCall: toolCalls[0],
type: 'call_tool' as const,
};
}
// 没有工具调用,添加普通 assistant 消息
state.messages.push(assistantMessage);
this.conversationState = 'done';
return { reason: 'LLM response completed', type: 'finish' as const };
}
case 'tool_result': {
// 工具执行结果阶段
const toolPayload = context.payload as { result: any; toolMessage: any };
console.log(`🛠️ 工具执行完成: ${JSON.stringify(toolPayload.result)}`);
// 移除已执行的工具
this.pendingToolCalls = this.pendingToolCalls.slice(1);
// 如果还有未执行的工具,继续执行
if (this.pendingToolCalls.length > 0) {
return {
toolCall: this.pendingToolCalls[0],
type: 'call_tool' as const,
};
}
// 所有工具执行完成,调用 LLM 处理结果
this.conversationState = 'processing_llm';
return {
payload: {
messages: state.messages,
tools: this.getToolDefinitions(),
},
type: 'call_llm' as const,
};
}
case 'human_response': {
// 人机交互响应阶段(简化示例中不使用)
return { reason: 'Human interaction not supported', type: 'finish' as const };
}
case 'error': {
// 错误阶段
const errorPayload = context.payload as { error: any };
console.error('❌ 错误状态:', errorPayload.error);
return { reason: 'Error occurred', type: 'finish' as const };
}
default: {
return { reason: 'Unknown phase', type: 'finish' as const };
}
}
}
}
// 主函数
async function main() {
console.log('🚀 简单的 OpenAI Tools Agent 示例\n');
if (!process.env.OPENAI_API_KEY) {
console.error('❌ 请设置 OPENAI_API_KEY 环境变量');
return;
}
// 创建 Agent 和 Runtime
const agent = new SimpleAgent();
const runtime = new AgentRuntime(agent); // modelRuntime 现在在 Agent 中
// 测试消息
const testMessage = process.argv[2] || 'What time is it? Also calculate 15 * 8 + 7';
console.log(`💬 用户: ${testMessage}\n`);
// 创建初始状态
let state = AgentRuntime.createInitialState({
maxSteps: 10,
messages: [{ content: testMessage, role: 'user' }],
sessionId: 'simple-test',
});
console.log('🤖 AI: ');
// 执行对话循环
let nextContext: RuntimeContext | undefined = undefined;
while (state.status !== 'done' && state.status !== 'error') {
const result = await runtime.step(state, nextContext);
// 处理事件
for (const event of result.events) {
switch (event.type) {
case 'llm_stream': {
if ((event as any).chunk.content) {
process.stdout.write((event as any).chunk.content);
}
break;
}
case 'llm_result': {
if ((event as any).result.tool_calls) {
console.log('\n\n🔧 需要调用工具...');
}
break;
}
case 'tool_result': {
console.log(`\n🛠️ 工具执行结果:`, event.result);
console.log('\n🤖 AI: ');
break;
}
case 'done': {
console.log('\n\n✅ 对话完成');
break;
}
case 'error': {
console.error('\n❌ 错误:', event.error);
break;
}
}
}
state = result.newState;
nextContext = result.nextContext; // 使用返回的 nextContext
}
console.log(`\n📊 总共执行了 ${state.stepCount} 个步骤`);
}
main().catch(console.error);
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@lobechat/agent-runtime",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"scripts": {
"simple": "tsx examples/tools-calling.ts",
"test": "vitest",
"test:coverage": "vitest --coverage"
},
"dependencies": {},
"devDependencies": {
"openai": "^4.0.0"
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export * from './runtime';
+656
View File
@@ -0,0 +1,656 @@
import type {
Agent,
AgentEvent,
AgentInstruction,
AgentState,
Cost,
InstructionExecutor,
RuntimeConfig,
RuntimeContext,
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?: RuntimeContext,
): Promise<{ events: AgentEvent[]; newState: AgentState; nextContext?: RuntimeContext }> {
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,
};
newState.events = [...newState.events, finishEvent];
return {
events: [finishEvent],
newState,
nextContext: undefined, // No next context when done
};
}
// Use provided context or create initial context
const runtimeContext = context || this.createInitialContext(newState);
let result: { events: AgentEvent[]; newState: AgentState; nextContext?: RuntimeContext };
// Handle human approved tool calls
if (runtimeContext.phase === 'human_approved_tool') {
const approvedPayload = runtimeContext.payload as { approvedToolCall: ToolsCalling };
result = await this.executors.call_tool(
{ toolCall: approvedPayload.approvedToolCall, type: 'call_tool' },
newState,
);
} else {
// Standard flow: Plan -> Execute
const instruction = await this.agent.runner(runtimeContext, newState);
result = await this.executors[instruction.type](instruction, newState);
}
// Ensure stepCount is preserved in the result
result.newState.stepCount = newState.stepCount;
result.newState.lastModified = newState.lastModified;
// Accumulate events to state history
result.newState.events = [...result.newState.events, ...result.events];
return result;
} 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: ToolsCalling,
): Promise<{ events: AgentEvent[]; newState: AgentState; nextContext?: RuntimeContext }> {
const context: RuntimeContext = {
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',
};
newState.events = [...newState.events, interruptEvent];
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?: RuntimeContext,
): Promise<{ events: AgentEvent[]; newState: AgentState; nextContext?: RuntimeContext }> {
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',
};
newState.events = [...newState.events, resumeEvent];
// 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 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();
// Default usage statistics
const defaultUsage: Usage = {
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,
},
};
// Default cost structure
const defaultCost: Cost = {
calculatedAt: now,
currency: 'USD',
llm: {
byModel: {},
currency: 'USD',
total: 0,
},
tools: {
byTool: {},
currency: 'USD',
total: 0,
},
total: 0,
};
return {
cost: defaultCost,
// Default values
createdAt: now,
events: [],
lastModified: now,
messages: [],
status: 'idle',
stepCount: 0,
usage: defaultUsage,
// User provided values override defaults
...partialState,
};
}
// ============ 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)) {
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: RuntimeContext = {
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 { 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);
const handler = tools[toolCall.function.name];
if (!handler) throw new Error(`Tool not found: ${toolCall.function.name}`);
const args = JSON.parse(toolCall.function.arguments);
const result = await handler(args);
newState.messages.push({
content: JSON.stringify(result),
role: 'tool',
tool_call_id: toolCall.id,
});
events.push({ id: toolCall.id, 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: RuntimeContext = {
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_input';
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_input';
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_input';
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 ============
/**
* Handle cost limit exceeded scenario
*/
private handleCostLimitExceeded(state: AgentState): {
events: AgentEvent[];
newState: AgentState;
nextContext?: RuntimeContext;
} {
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,
};
newState.events = [...newState.events, finishEvent];
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,
};
newState.events = [...newState.events, warningEvent];
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 {
eventCount: state.events.length,
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): RuntimeContext {
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;
// Accumulate error event to state history
errorState.events = [...errorState.events, errorEvent];
return {
events: [errorEvent],
newState: errorState,
};
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './core';
export * from './types';
+121
View File
@@ -0,0 +1,121 @@
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
import type { AgentState, ToolsCalling } from './state';
export interface AgentEventInit {
type: 'init';
}
export interface AgentEventLlmStart {
type: 'llm_start';
payload: unknown;
}
export interface AgentEventLlmStream {
type: 'llm_stream';
chunk: unknown;
}
export interface AgentEventLlmResult {
type: 'llm_result';
result: unknown;
}
export interface AgentEventToolPending {
type: 'tool_pending';
toolCalls: ToolsCalling[];
}
export interface AgentEventToolResult {
type: 'tool_result';
id: string;
result: any;
}
export interface AgentEventHumanApproveRequired {
type: 'human_approve_required';
pendingToolsCalling: ToolsCalling[];
sessionId: string;
}
export interface AgentEventHumanPromptRequired {
type: 'human_prompt_required';
metadata?: Record<string, unknown>;
prompt: string;
sessionId: string;
}
export interface AgentEventHumanSelectRequired {
type: 'human_select_required';
metadata?: Record<string, unknown>;
multi?: boolean;
options: { label: string; value: string }[];
prompt?: string;
sessionId: string;
}
/**
* Standardized finish reasons
*/
export type FinishReason =
| 'completed' // Normal completion
| 'user_requested' // User requested to end
| 'max_steps_exceeded' // Reached maximum steps limit
| 'cost_limit_exceeded' // Reached cost limit
| 'timeout' // Execution timeout
| 'agent_decision' // Agent decided to finish
| 'error_recovery' // Finished due to unrecoverable error
| 'system_shutdown'; // System is shutting down
export interface AgentEventDone {
type: 'done';
finalState: AgentState;
reason: FinishReason;
reasonDetail?: string;
}
export interface AgentEventError {
type: 'error';
error: any;
}
export interface AgentEventInterrupted {
type: 'interrupted';
reason: string;
interruptedAt: string;
interruptedInstruction?: any;
canResume: boolean;
metadata?: Record<string, unknown>;
}
export interface AgentEventResumed {
type: 'resumed';
reason: string;
resumedAt: string;
resumedFromStep: number;
metadata?: Record<string, unknown>;
}
/**
* Events emitted by the AgentRuntime during execution
*/
export type AgentEvent =
// Initialization
| AgentEventInit
// LLM streaming output
| AgentEventLlmStart
| AgentEventLlmStream
| AgentEventLlmResult
// Tool invocation
| AgentEventToolPending
| AgentEventToolResult
// Normal completion
| AgentEventDone
// Error thrown
| AgentEventError
// Human-in-the-loop (HIL)
| AgentEventHumanApproveRequired
| AgentEventHumanPromptRequired
| AgentEventHumanSelectRequired
// Interruption and resumption
| AgentEventInterrupted
| AgentEventResumed;
@@ -0,0 +1,5 @@
export * from './event';
export * from './instruction';
export * from './runtime';
export * from './state';
export * from './usage';
@@ -0,0 +1,125 @@
import type { FinishReason } from './event';
import { AgentState, ToolRegistry, ToolsCalling } from './state';
import type { Cost, CostCalculationContext, Usage } from './usage';
/**
* Runtime execution context passed to Agent runner
*/
export interface RuntimeContext {
/** Phase-specific payload/context */
payload?: unknown;
/** Current execution phase */
phase:
| 'init'
| 'user_input'
| 'llm_result'
| 'tool_result'
| 'human_response'
| 'human_approved_tool'
| 'error';
/** Session metadata */
session: {
eventCount: number;
messageCount: number;
sessionId: string;
status: AgentState['status'];
stepCount: number;
};
}
/**
* Represents the "Brain" of an agent.
* It contains all the decision-making logic and is completely stateless.
*/
export interface Agent {
/**
* Calculate cost from usage statistics
* @param context - Cost calculation context with usage and limits
* @returns Updated cost information
*/
calculateCost?(context: CostCalculationContext): Cost;
/**
* Calculate usage statistics from operation results
* @param operationType - Type of operation that was performed
* @param operationResult - Result data from the operation
* @param previousUsage - Previous usage statistics
* @returns Updated usage statistics
*/
calculateUsage?(
operationType: 'llm' | 'tool' | 'human_interaction',
operationResult: any,
previousUsage: Usage,
): Usage;
/** Optional custom executors mapping to extend runtime behaviors */
executors?: Partial<Record<AgentInstruction['type'], any>>;
/**
* Model runtime function for LLM calls - Agent owns its model integration
* @param payload - LLM call payload (messages, tools, etc.)
* @returns Async iterable of streaming response chunks
*/
modelRuntime?: (payload: unknown) => AsyncIterable<any>;
/**
* The core runner method. Based on the current execution context and state,
* it decides what the next action should be.
* @param context - Current runtime context with phase and payload
* @param state - Complete agent state for reference
*/
runner(context: RuntimeContext, state: AgentState): Promise<AgentInstruction>;
/** Optional tools registry held by the agent */
tools?: ToolRegistry;
}
export interface AgentInstructionCallLlm {
payload: unknown;
type: 'call_llm';
}
export interface AgentInstructionCallTool {
toolCall: ToolsCalling;
type: 'call_tool';
}
export interface AgentInstructionRequestHumanPrompt {
metadata?: Record<string, unknown>;
prompt: string;
reason?: string;
type: 'request_human_prompt';
}
export interface AgentInstructionRequestHumanSelect {
metadata?: Record<string, unknown>;
multi?: boolean;
options: Array<{ label: string; value: string }>;
prompt?: string;
reason?: string;
type: 'request_human_select';
}
export interface AgentInstructionRequestHumanApprove {
pendingToolsCalling: ToolsCalling[];
reason?: string;
type: 'request_human_approve';
}
export interface AgentInstructionFinish {
reason: FinishReason;
reasonDetail?: string;
type: 'finish';
}
/**
* A serializable instruction object that the "Agent" (Brain) returns
* to the "AgentRuntime" (Engine) to execute.
*/
export type AgentInstruction =
| AgentInstructionCallLlm
| AgentInstructionCallTool
| AgentInstructionRequestHumanPrompt
| AgentInstructionRequestHumanSelect
| AgentInstructionRequestHumanApprove
| AgentInstructionFinish;
@@ -0,0 +1,18 @@
import type { AgentEvent } from './event';
import { AgentInstruction, RuntimeContext } from './instruction';
import { AgentState } from './state';
export type InstructionExecutor = (
instruction: AgentInstruction,
state: AgentState,
) => Promise<{
events: AgentEvent[];
newState: AgentState;
/** Next context to pass to Agent runner (if execution should continue) */
nextContext?: RuntimeContext;
}>;
export interface RuntimeConfig {
/** Custom executors for specific instruction types */
executors?: Partial<Record<AgentInstruction['type'], InstructionExecutor>>;
}
+108
View File
@@ -0,0 +1,108 @@
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
import type { AgentEvent } from './event';
import type { Cost, CostLimit, Usage } from './usage';
/**
* Agent's serializable state.
* This is the "passport" that can be persisted and transferred.
*/
export interface AgentState {
sessionId: string;
// --- State Machine ---
status: 'idle' | 'running' | 'waiting_for_human_input' | 'done' | 'error' | 'interrupted';
// --- Core Context ---
messages: any[];
systemRole?: string;
// --- Event History ---
/**
* Complete event trace for this agent session.
* Useful for debugging, auditing, and state replay.
*/
events: AgentEvent[];
// --- Execution Tracking ---
/**
* Number of execution steps in this session.
* Incremented on each runtime.step() call.
*/
stepCount: number;
/**
* Optional maximum number of steps allowed.
* If set, execution will stop with error when exceeded.
*/
maxSteps?: number;
// --- Usage and Cost Tracking ---
/**
* Accumulated usage statistics for this session.
* Tracks tokens, API calls, tool usage, etc.
*/
usage: Usage;
/**
* Current calculated cost for this session.
* Updated after each billable operation.
*/
cost: Cost;
/**
* Optional cost limits configuration.
* If set, execution will stop when limits are exceeded.
*/
costLimit?: CostLimit;
// --- HIL ---
/**
* When status is 'waiting_for_human_input', this stores pending requests
* for human-in-the-loop operations.
*/
pendingToolsCalling?: ToolsCalling[];
pendingHumanPrompt?: { metadata?: Record<string, unknown>; prompt: string };
pendingHumanSelect?: {
metadata?: Record<string, unknown>;
multi?: boolean;
options: Array<{ label: string; value: string }>;
prompt?: string;
};
// --- Interruption Handling ---
/**
* When status is 'interrupted', this stores the interruption context
* for potential resumption or cleanup.
*/
interruption?: {
/** Reason for interruption */
reason: string;
/** Timestamp when interruption occurred */
interruptedAt: string;
/** The instruction that was being executed when interrupted */
interruptedInstruction?: any;
/** Whether the interruption can be resumed */
canResume: boolean;
};
// --- Metadata ---
createdAt: string;
error?: any;
lastModified: string;
// --- Extensible metadata ---
metadata?: Record<string, any>;
}
/**
* OpenAI Tool Call
*/
export interface ToolsCalling {
function: {
arguments: string;
name: string; // A JSON string of arguments
};
id: string;
type: 'function';
}
/**
* A registry for tools, mapping tool names to their implementation.
*/
export type ToolRegistry = Record<string, (args: any) => Promise<any>>;
+128
View File
@@ -0,0 +1,128 @@
/**
* Token usage tracking for different types of operations
*/
export interface TokenUsage {
/** Input tokens consumed */
input: number;
/** Output tokens generated */
output: number;
/** Total tokens (input + output) */
total: number;
}
/**
* Usage statistics for different operation types
*/
export interface Usage {
/** Human interaction statistics */
humanInteraction: {
/** Number of approval requests */
approvalRequests: number;
/** Number of prompt requests */
promptRequests: number;
/** Number of selection requests */
selectRequests: number;
/** Total waiting time for human input */
totalWaitingTimeMs: number;
};
/** LLM model usage */
llm: {
/** Number of LLM API calls made */
apiCalls: number;
/** Total processing time in milliseconds */
processingTimeMs: number;
/** Total token usage across all LLM calls */
tokens: TokenUsage;
};
/** Tool usage statistics */
tools: {
/** Usage breakdown by tool name */
byTool: Record<
string,
{
calls: number;
errors: number;
totalTimeMs: number;
}
>;
/** Number of tool calls executed */
totalCalls: number;
/** Total tool execution time */
totalTimeMs: number;
};
}
/**
* Cost calculation result
*/
export interface Cost {
/** Cost calculation timestamp */
calculatedAt: string;
currency: string;
/** LLM API costs */
llm: {
/** Cost per model used */
byModel: Record<
string,
{
currency: string;
inputTokens: number;
outputTokens: number;
totalCost: number;
}
>;
currency: string;
/** Total LLM cost */
total: number;
};
/** Tool execution costs */
tools: {
/** Cost per tool (if tool has associated costs) */
byTool: Record<
string,
{
calls: number;
currency: string;
totalCost: number;
}
>;
currency: string;
/** Total tool cost */
total: number;
};
/** Total session cost */
total: number;
}
/**
* Cost limit configuration
*/
export interface CostLimit {
/** Currency for cost limits */
currency: string;
/** Maximum LLM cost allowed */
maxLlmCost?: number;
/** Maximum tool cost allowed */
maxToolCost?: number;
/** Maximum total cost allowed */
maxTotalCost: number;
/** Action to take when limit is exceeded */
onExceeded: 'stop' | 'warn' | 'interrupt';
}
/**
* Cost calculation context passed to Agent
*/
export interface CostCalculationContext {
/** Cost limits configuration */
costLimit?: CostLimit;
/** Previous cost calculation (if any) */
previousCost?: Cost;
/** Current usage statistics */
usage: Usage;
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
reporter: ['text', 'json', 'lcov', 'text-summary'],
},
environment: 'happy-dom',
},
});