feat: planning, action, chat, memory agent implementation (#1)

This commit is contained in:
RainbowBird
2025-01-22 00:41:51 +08:00
parent a550722aef
commit 0ba9cf631f
56 changed files with 2812 additions and 1036 deletions
@@ -0,0 +1,180 @@
import type { Mineflayer } from '../../libs/mineflayer'
import type { Action } from '../../libs/mineflayer/action'
import type { ActionAgent, AgentConfig } from '../../libs/mineflayer/base-agent'
import { useBot } from '../../composables/bot'
import { AbstractAgent } from '../../libs/mineflayer/base-agent'
import { ActionManager } from '../../manager/action'
import { actionsList } from './tools'
interface ActionState {
executing: boolean
label: string
startTime: number
}
/**
* ActionAgentImpl implements the ActionAgent interface to handle action execution
* Manages action lifecycle, state tracking and error handling
*/
export class ActionAgentImpl extends AbstractAgent implements ActionAgent {
public readonly type = 'action' as const
private actions: Map<string, Action>
private actionManager: ActionManager
private mineflayer: Mineflayer
private currentActionState: ActionState
constructor(config: AgentConfig) {
super(config)
this.actions = new Map()
this.mineflayer = useBot().bot
this.actionManager = new ActionManager(this.mineflayer)
this.currentActionState = {
executing: false,
label: '',
startTime: 0,
}
}
protected async initializeAgent(): Promise<void> {
this.logger.log('Initializing action agent')
actionsList.forEach(action => this.actions.set(action.name, action))
// Set up event listeners
// todo: nothing to call here
this.on('message', async ({ sender, message }) => {
await this.handleAgentMessage(sender, message)
})
}
protected async destroyAgent(): Promise<void> {
await this.actionManager.stop()
this.actionManager.cancelResume()
this.actions.clear()
this.removeAllListeners()
this.currentActionState = {
executing: false,
label: '',
startTime: 0,
}
}
public async performAction(
name: string,
params: unknown[],
options: { timeout?: number, resume?: boolean } = {},
): Promise<string> {
if (!this.initialized) {
throw new Error('Action agent not initialized')
}
const action = this.actions.get(name)
if (!action) {
throw new Error(`Action not found: ${name}`)
}
try {
this.updateActionState(true, name)
this.logger.withFields({ name, params }).log('Performing action')
const result = await this.actionManager.runAction(
name,
async () => {
const fn = action.perform(this.mineflayer)
return await fn(...params)
},
{
timeout: options.timeout ?? 60,
resume: options.resume ?? false,
},
)
if (!result.success) {
throw new Error(result.message ?? 'Action failed')
}
return this.formatActionOutput({
message: result.message,
timedout: result.timedout,
interrupted: false,
})
}
catch (error) {
this.logger.withFields({ name, params, error }).error('Failed to perform action')
throw error
}
finally {
this.updateActionState(false)
}
}
public async resumeAction(name: string, params: unknown[]): Promise<string> {
const action = this.actions.get(name)
if (!action) {
throw new Error(`Action not found: ${name}`)
}
try {
this.updateActionState(true, name)
const result = await this.actionManager.resumeAction(
name,
async () => {
const fn = action.perform(this.mineflayer)
return await fn(...params)
},
60,
)
if (!result.success) {
throw new Error(result.message ?? 'Action failed')
}
return this.formatActionOutput({
message: result.message,
timedout: result.timedout,
interrupted: false,
})
}
catch (error) {
this.logger.withFields({ name, params, error }).error('Failed to resume action')
throw error
}
finally {
this.updateActionState(false)
}
}
public getAvailableActions(): Action[] {
return Array.from(this.actions.values())
}
private async handleAgentMessage(sender: string, message: string): Promise<void> {
if (sender === 'system') {
if (message.includes('interrupt')) {
await this.actionManager.stop()
}
}
else {
this.logger.withFields({ sender, message }).log('Processing agent message')
}
}
private updateActionState(executing: boolean, label = ''): void {
this.currentActionState = {
executing,
label,
startTime: executing ? Date.now() : 0,
}
this.emit('actionStateChanged', this.currentActionState)
}
private formatActionOutput(result: { message: string | null, timedout: boolean, interrupted: boolean }): string {
if (result.timedout) {
return `Action timed out: ${result.message}`
}
if (result.interrupted) {
return 'Action was interrupted'
}
return result.message ?? ''
}
}
@@ -1,11 +1,11 @@
import { messages, system, user } from 'neuri/openai'
import { beforeAll, describe, expect, it } from 'vitest'
import { initBot, useBot } from '../composables/bot'
import { botConfig, initEnv } from '../composables/config'
import { genSystemBasicPrompt } from '../prompts/agent'
import { initLogger } from '../utils/logger'
import { initAgent } from './openai'
import { initBot, useBot } from '../../composables/bot'
import { botConfig, initEnv } from '../../composables/config'
import { createNeuriAgent } from '../../composables/neuri'
import { initLogger } from '../../utils/logger'
import { generateSystemBasicPrompt } from '../prompt/llm-agent.plugin'
describe('openAI agent', { timeout: 0 }, () => {
beforeAll(() => {
@@ -16,13 +16,13 @@ describe('openAI agent', { timeout: 0 }, () => {
it('should initialize the agent', async () => {
const { bot } = useBot()
const agent = await initAgent(bot)
const agent = await createNeuriAgent(bot)
await new Promise<void>((resolve) => {
bot.bot.once('spawn', async () => {
const text = await agent.handle(
messages(
system(genSystemBasicPrompt('airi')),
system(generateSystemBasicPrompt('airi')),
user('Hello, who are you?'),
),
async (c) => {
@@ -0,0 +1,49 @@
import type { Agent } from 'neuri'
import type { Message } from 'neuri/openai'
import type { Mineflayer } from '../../libs/mineflayer'
import { useLogg } from '@guiiai/logg'
import { agent } from 'neuri'
import { BaseLLMHandler } from '../../libs/llm/base'
import { actionsList } from './tools'
export async function createActionNeuriAgent(mineflayer: Mineflayer): Promise<Agent> {
const logger = useLogg('action-neuri').useGlobalConfig()
logger.log('Initializing action agent')
let actionAgent = agent('action')
Object.values(actionsList).forEach((action) => {
actionAgent = actionAgent.tool(
action.name,
action.schema,
async ({ parameters }) => {
logger.withFields({ name: action.name, parameters }).log('Calling action')
mineflayer.memory.actions.push(action)
const fn = action.perform(mineflayer)
return await fn(...Object.values(parameters))
},
{ description: action.description },
)
})
return actionAgent.build()
}
export class ActionLLMHandler extends BaseLLMHandler {
public async handleAction(messages: Message[]): Promise<string> {
const result = await this.config.agent.handleStateless(messages, async (context) => {
this.logger.log('Processing action...')
const retryHandler = this.createRetryHandler(
async ctx => (await this.handleCompletion(ctx, 'action', ctx.messages)).content,
)
return await retryHandler(context)
})
if (!result) {
throw new Error('Failed to process action')
}
return result
}
}
@@ -0,0 +1,61 @@
import { messages, system, user } from 'neuri/openai'
import { beforeAll, describe, expect, it } from 'vitest'
import { initBot, useBot } from '../../composables/bot'
import { botConfig, initEnv } from '../../composables/config'
import { createNeuriAgent } from '../../composables/neuri'
import { sleep } from '../../utils/helper'
import { initLogger } from '../../utils/logger'
import { generateActionAgentPrompt } from '../prompt/llm-agent.plugin'
describe('actions agent', { timeout: 0 }, () => {
beforeAll(() => {
initLogger()
initEnv()
initBot({ botConfig })
})
it('should choose right query command', async () => {
const { bot } = useBot()
const agent = await createNeuriAgent(bot)
await new Promise<void>((resolve) => {
bot.bot.once('spawn', async () => {
const text = await agent.handle(messages(
system(generateActionAgentPrompt(bot)),
user('What\'s your status?'),
), async (c) => {
const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' })
return await completion?.firstContent()
})
expect(text?.toLowerCase()).toContain('position')
resolve()
})
})
})
it('should choose right action command', async () => {
const { bot } = useBot()
const agent = await createNeuriAgent(bot)
await new Promise<void>((resolve) => {
bot.bot.on('spawn', async () => {
const text = await agent.handle(messages(
system(generateActionAgentPrompt(bot)),
user('goToPlayer: luoling8192'),
), async (c) => {
const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' })
return await completion?.firstContent()
})
expect(text).toContain('goToPlayer')
await sleep(10000)
resolve()
})
})
})
})
@@ -1,13 +1,13 @@
import type { Action } from '../libs/mineflayer'
import type { Action } from '../../libs/mineflayer'
import { useLogg } from '@guiiai/logg'
import { z } from 'zod'
import * as world from '../composables/world'
import * as skills from '../skills'
import { collectBlock } from '../skills/actions/collect-block'
import { discard, equip, putInChest, takeFromChest, viewChest } from '../skills/actions/inventory'
import { activateNearestBlock, placeBlock } from '../skills/actions/world-interactions'
import * as skills from '../../skills'
import { collectBlock } from '../../skills/actions/collect-block'
import { discard, equip, putInChest, takeFromChest, viewChest } from '../../skills/actions/inventory'
import { activateNearestBlock, placeBlock } from '../../skills/actions/world-interactions'
import * as world from '../../skills/world'
// Utils
const pad = (str: string): string => `\n${str}\n`
@@ -1,90 +0,0 @@
import { messages, system, user } from 'neuri/openai'
import { beforeAll, describe, expect, it } from 'vitest'
import { initBot, useBot } from '../composables/bot'
import { botConfig, initEnv } from '../composables/config'
import { genActionAgentPrompt, genQueryAgentPrompt } from '../prompts/agent'
import { sleep } from '../utils/helper'
import { initLogger } from '../utils/logger'
import { initAgent } from './openai'
describe('actions agent', { timeout: 0 }, () => {
beforeAll(() => {
initLogger()
initEnv()
initBot({ botConfig })
})
it('should choose right query command', async () => {
const { bot } = useBot()
const agent = await initAgent(bot)
await new Promise<void>((resolve) => {
bot.bot.once('spawn', async () => {
const text = await agent.handle(messages(
system(genQueryAgentPrompt(bot)),
user('What\'s your status?'),
), async (c) => {
const completion = await c.reroute('query', c.messages, { model: 'openai/gpt-4o-mini' })
return await completion?.firstContent()
})
expect(text?.toLowerCase()).toContain('position')
resolve()
})
})
})
it('should choose right action command', async () => {
const { bot } = useBot()
const agent = await initAgent(bot)
// console.log(JSON.stringify(agent, null, 2))
await new Promise<void>((resolve) => {
bot.bot.on('spawn', async () => {
const text = await agent.handle(messages(
system(genActionAgentPrompt(bot)),
user('goToPlayer: luoling8192'),
), async (c) => {
const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' })
return await completion?.firstContent()
})
expect(text?.toLowerCase()).toContain('goToPlayer')
await sleep(10000)
resolve()
})
})
})
// it('should split question into actions', async () => {
// const { ctx } = useBot()
// const agent = await initAgent(ctx)
// function testFn() {
// return new Promise<void>((resolve) => {
// ctx.bot.on('spawn', async () => {
// const text = await agent.handle(messages(
// system(genActionAgentPrompt(ctx)),
// user('Help me to cut down the tree'),
// ), async (c) => {
// const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' })
// console.log(completion)
// return await completion?.firstContent()
// })
// console.log(text)
// resolve()
// })
// })
// }
// await testFn()
// })
})
+168
View File
@@ -0,0 +1,168 @@
import type { ChatAgent } from '../../libs/mineflayer/base-agent'
import type { ChatAgentConfig, ChatContext } from './types'
import { AbstractAgent } from '../../libs/mineflayer/base-agent'
import { generateChatResponse } from './llm'
export class ChatAgentImpl extends AbstractAgent implements ChatAgent {
public readonly type = 'chat' as const
private activeChats: Map<string, ChatContext>
private maxHistoryLength: number
private idleTimeout: number
private llmConfig: ChatAgentConfig['llm']
constructor(config: ChatAgentConfig) {
super(config)
this.activeChats = new Map()
this.maxHistoryLength = config.maxHistoryLength ?? 50
this.idleTimeout = config.idleTimeout ?? 5 * 60 * 1000 // 5 minutes
this.llmConfig = config.llm
}
protected async initializeAgent(): Promise<void> {
this.logger.log('Initializing chat agent')
this.on('message', async ({ sender, message }) => {
await this.handleAgentMessage(sender, message)
})
setInterval(() => {
this.checkIdleChats()
}, 60 * 1000)
}
protected async destroyAgent(): Promise<void> {
this.activeChats.clear()
this.removeAllListeners()
}
public async processMessage(message: string, sender: string): Promise<string> {
if (!this.initialized) {
throw new Error('Chat agent not initialized')
}
this.logger.withFields({ sender, message }).log('Processing message')
try {
// Get or create chat context
const context = this.getOrCreateContext(sender)
// Add message to history
this.addToHistory(context, sender, message)
// Update last activity time
context.lastUpdate = Date.now()
// Generate response using LLM
const response = await this.generateResponse(message, context)
// Add response to history
this.addToHistory(context, this.id, response)
return response
}
catch (error) {
this.logger.withError(error).error('Failed to process message')
throw error
}
}
public startConversation(player: string): void {
if (!this.initialized) {
throw new Error('Chat agent not initialized')
}
this.logger.withField('player', player).log('Starting conversation')
const context = this.getOrCreateContext(player)
context.startTime = Date.now()
context.lastUpdate = Date.now()
}
public endConversation(player: string): void {
if (!this.initialized) {
throw new Error('Chat agent not initialized')
}
this.logger.withField('player', player).log('Ending conversation')
if (this.activeChats.has(player)) {
const context = this.activeChats.get(player)!
// Archive chat history if needed
this.archiveChat(context)
this.activeChats.delete(player)
}
}
private getOrCreateContext(player: string): ChatContext {
let context = this.activeChats.get(player)
if (!context) {
context = {
player,
startTime: Date.now(),
lastUpdate: Date.now(),
history: [],
}
this.activeChats.set(player, context)
}
return context
}
private addToHistory(context: ChatContext, sender: string, message: string): void {
context.history.push({
sender,
message,
timestamp: Date.now(),
})
// Trim history if too long
if (context.history.length > this.maxHistoryLength) {
context.history = context.history.slice(-this.maxHistoryLength)
}
}
private async generateResponse(message: string, context: ChatContext): Promise<string> {
return await generateChatResponse(message, context.history, {
agent: this.llmConfig.agent,
model: this.llmConfig.model,
maxContextLength: this.maxHistoryLength,
})
}
private checkIdleChats(): void {
const now = Date.now()
for (const [player, context] of this.activeChats.entries()) {
if (now - context.lastUpdate > this.idleTimeout) {
this.logger.withField('player', player).log('Ending idle conversation')
this.endConversation(player)
}
}
}
private async archiveChat(context: ChatContext): Promise<void> {
// Archive chat history to persistent storage if needed
this.logger.withFields({
player: context.player,
messageCount: context.history.length,
duration: Date.now() - context.startTime,
}).log('Archiving chat history')
}
private async handleAgentMessage(sender: string, message: string): Promise<void> {
if (sender === 'system') {
if (message.includes('interrupt')) {
// Handle system interrupt
for (const player of this.activeChats.keys()) {
this.endConversation(player)
}
}
}
else {
// Handle messages from other agents
const context = this.activeChats.get(sender)
if (context) {
await this.processMessage(message, sender)
}
}
}
}
@@ -0,0 +1,46 @@
import type { ChatHistory } from './types'
import { system, user } from 'neuri/openai'
import { BaseLLMHandler } from '../../libs/llm/base'
import { genChatAgentPrompt } from '../prompt/chat'
export class ChatLLMHandler extends BaseLLMHandler {
public async generateResponse(
message: string,
history: ChatHistory[],
): Promise<string> {
const systemPrompt = genChatAgentPrompt()
const chatHistory = this.formatChatHistory(history, this.config.maxContextLength ?? 10)
const messages = [
system(systemPrompt),
...chatHistory,
user(message),
]
const result = await this.config.agent.handleStateless(messages, async (context) => {
this.logger.log('Generating response...')
const retryHandler = this.createRetryHandler(
async ctx => (await this.handleCompletion(ctx, 'chat', ctx.messages)).content,
)
return await retryHandler(context)
})
if (!result) {
throw new Error('Failed to generate response')
}
return result
}
private formatChatHistory(
history: ChatHistory[],
maxLength: number,
): Array<{ role: 'user' | 'assistant', content: string }> {
const recentHistory = history.slice(-maxLength)
return recentHistory.map(entry => ({
role: entry.sender === 'bot' ? 'assistant' : 'user',
content: entry.message,
}))
}
}
+85
View File
@@ -0,0 +1,85 @@
import type { Agent, Neuri } from 'neuri'
import type { ChatHistory } from './types'
import { useLogg } from '@guiiai/logg'
import { agent } from 'neuri'
import { system, user } from 'neuri/openai'
import { toRetriable } from '../../utils/helper'
import { genChatAgentPrompt } from '../prompt/chat'
const logger = useLogg('chat-llm').useGlobalConfig()
interface LLMChatConfig {
agent: Neuri
model?: string
retryLimit?: number
delayInterval?: number
maxContextLength?: number
}
export async function createChatNeuriAgent(): Promise<Agent> {
return agent('chat').build()
}
export async function generateChatResponse(
message: string,
history: ChatHistory[],
config: LLMChatConfig,
): Promise<string> {
const systemPrompt = genChatAgentPrompt()
const chatHistory = formatChatHistory(history, config.maxContextLength ?? 10)
const userPrompt = message
const messages = [
system(systemPrompt),
...chatHistory,
user(userPrompt),
]
const content = await config.agent.handleStateless(messages, async (c) => {
logger.log('Generating response...')
const handleCompletion = async (c: any): Promise<string> => {
const completion = await c.reroute('chat', c.messages, {
model: config.model ?? 'openai/gpt-4o-mini',
})
if (!completion || 'error' in completion) {
logger.withFields(c).error('Completion failed')
throw new Error(completion?.error?.message ?? 'Unknown error')
}
const content = await completion.firstContent()
logger.withFields({ usage: completion.usage, content }).log('Response generated')
return content
}
const retriableHandler = toRetriable<any, string>(
config.retryLimit ?? 3,
config.delayInterval ?? 1000,
handleCompletion,
)
return await retriableHandler(c)
})
if (!content) {
throw new Error('Failed to generate response')
}
return content
}
function formatChatHistory(
history: ChatHistory[],
maxLength: number,
): Array<{ role: 'user' | 'assistant', content: string }> {
// Take the most recent messages up to maxLength
const recentHistory = history.slice(-maxLength)
return recentHistory.map(entry => ({
role: entry.sender === 'bot' ? 'assistant' : 'user',
content: entry.message,
}))
}
@@ -0,0 +1,25 @@
import type { Neuri } from 'neuri'
export interface ChatHistory {
sender: string
message: string
timestamp: number
}
export interface ChatContext {
player: string
startTime: number
lastUpdate: number
history: ChatHistory[]
}
export interface ChatAgentConfig {
id: string
type: 'chat'
llm: {
agent: Neuri
model?: string
}
maxHistoryLength?: number
idleTimeout?: number
}
@@ -0,0 +1,108 @@
import type { Message } from 'neuri/openai'
import type { Action } from '../../libs/mineflayer'
import type { AgentConfig, MemoryAgent } from '../../libs/mineflayer/base-agent'
import { useLogg } from '@guiiai/logg'
import { Memory } from '../../libs/mineflayer/memory'
const logger = useLogg('memory-agent').useGlobalConfig()
export class MemoryAgentImpl implements MemoryAgent {
public readonly type = 'memory' as const
public readonly id: string
private memory: Map<string, unknown>
private initialized: boolean
private memoryInstance: Memory
constructor(config: AgentConfig) {
this.id = config.id
this.memory = new Map()
this.initialized = false
this.memoryInstance = new Memory()
}
async init(): Promise<void> {
if (this.initialized) {
return
}
logger.log('Initializing memory agent')
this.initialized = true
}
async destroy(): Promise<void> {
this.memory.clear()
this.initialized = false
}
remember(key: string, value: unknown): void {
if (!this.initialized) {
throw new Error('Memory agent not initialized')
}
logger.withFields({ key, value }).log('Storing memory')
this.memory.set(key, value)
}
recall<T>(key: string): T | undefined {
if (!this.initialized) {
throw new Error('Memory agent not initialized')
}
const value = this.memory.get(key) as T | undefined
logger.withFields({ key, value }).log('Recalling memory')
return value
}
forget(key: string): void {
if (!this.initialized) {
throw new Error('Memory agent not initialized')
}
logger.withFields({ key }).log('Forgetting memory')
this.memory.delete(key)
}
getMemorySnapshot(): Record<string, unknown> {
if (!this.initialized) {
throw new Error('Memory agent not initialized')
}
return Object.fromEntries(this.memory.entries())
}
addChatMessage(message: Message): void {
if (!this.initialized) {
throw new Error('Memory agent not initialized')
}
this.memoryInstance.chatHistory.push(message)
logger.withFields({ message }).log('Adding chat message to memory')
}
addAction(action: Action): void {
if (!this.initialized) {
throw new Error('Memory agent not initialized')
}
this.memoryInstance.actions.push(action)
logger.withFields({ action }).log('Adding action to memory')
}
getChatHistory(): Message[] {
if (!this.initialized) {
throw new Error('Memory agent not initialized')
}
return this.memoryInstance.chatHistory
}
getActions(): Action[] {
if (!this.initialized) {
throw new Error('Memory agent not initialized')
}
return this.memoryInstance.actions
}
}
-59
View File
@@ -1,59 +0,0 @@
import type { Agent, Neuri } from 'neuri'
import type { Mineflayer } from '../libs/mineflayer'
import { useLogg } from '@guiiai/logg'
import { agent, neuri } from 'neuri'
import { openaiConfig } from '../composables/config'
import { actionsList } from './actions'
let neuriAgent: Neuri | undefined
const agents = new Set<Agent | Promise<Agent>>()
const logger = useLogg('openai').useGlobalConfig()
export async function initAgent(mineflayer: Mineflayer): Promise<Neuri> {
logger.log('Initializing agent')
let n = neuri()
agents.add(initActionAgent(mineflayer))
agents.forEach(agent => n = n.agent(agent))
neuriAgent = await n.build({
provider: {
apiKey: openaiConfig.apiKey,
baseURL: openaiConfig.baseUrl,
},
})
return neuriAgent
}
export function getAgent(): Neuri {
if (!neuriAgent) {
throw new Error('Agent not initialized')
}
return neuriAgent
}
export async function initActionAgent(mineflayer: Mineflayer): Promise<Agent> {
logger.log('Initializing action agent')
let actionAgent = agent('action')
Object.values(actionsList).forEach((action) => {
actionAgent = actionAgent.tool(
action.name,
action.schema,
async ({ parameters }) => {
logger.withFields({ name: action.name, parameters }).log('Calling action')
mineflayer.memory.actions.push(action)
const fn = action.perform(mineflayer)
return await fn(...Object.values(parameters))
},
{ description: action.description },
)
})
return actionAgent.build()
}
@@ -0,0 +1,666 @@
import type { Neuri } from 'neuri'
import type { Action } from '../../libs/mineflayer/action'
import type { ActionAgent, AgentConfig, MemoryAgent, Plan, PlanningAgent } from '../../libs/mineflayer/base-agent'
import { AbstractAgent } from '../../libs/mineflayer/base-agent'
import { ActionAgentImpl } from '../action'
import { PlanningLLMHandler } from './llm-handler'
interface PlanContext {
goal: string
currentStep: number
startTime: number
lastUpdate: number
retryCount: number
isGenerating: boolean
pendingSteps: Array<{
action: string
params: unknown[]
}>
}
interface PlanTemplate {
goal: string
conditions: string[]
steps: Array<{
action: string
params: unknown[]
}>
requiresAction: boolean
}
export interface PlanningAgentConfig extends AgentConfig {
llm: {
agent: Neuri
model?: string
}
}
export class PlanningAgentImpl extends AbstractAgent implements PlanningAgent {
public readonly type = 'planning' as const
private currentPlan: Plan | null = null
private context: PlanContext | null = null
private actionAgent: ActionAgent | null = null
private memoryAgent: MemoryAgent | null = null
private planTemplates: Map<string, PlanTemplate>
private llmConfig: PlanningAgentConfig['llm']
private llmHandler: PlanningLLMHandler
constructor(config: PlanningAgentConfig) {
super(config)
this.planTemplates = new Map()
this.llmConfig = config.llm
this.initializePlanTemplates()
this.llmHandler = new PlanningLLMHandler({
agent: this.llmConfig.agent,
model: this.llmConfig.model,
})
}
protected async initializeAgent(): Promise<void> {
this.logger.log('Initializing planning agent')
// Create action agent directly
this.actionAgent = new ActionAgentImpl({
id: 'action',
type: 'action',
})
await this.actionAgent.init()
// Set event listener
this.on('message', async ({ sender, message }) => {
await this.handleAgentMessage(sender, message)
})
this.on('interrupt', () => {
this.handleInterrupt()
})
}
protected async destroyAgent(): Promise<void> {
this.currentPlan = null
this.context = null
this.actionAgent = null
this.memoryAgent = null
this.planTemplates.clear()
this.removeAllListeners()
}
public async createPlan(goal: string): Promise<Plan> {
if (!this.initialized) {
throw new Error('Planning agent not initialized')
}
this.logger.withField('goal', goal).log('Creating plan')
try {
// Check memory for existing plan
const cachedPlan = await this.loadCachedPlan(goal)
if (cachedPlan) {
this.logger.log('Using cached plan')
return cachedPlan
}
// Get available actions from action agent
const availableActions = this.actionAgent?.getAvailableActions() ?? []
// Check if the goal requires actions
const requirements = this.parseGoalRequirements(goal)
const requiresAction = this.doesGoalRequireAction(requirements)
// If no actions needed, return empty plan
if (!requiresAction) {
this.logger.log('Goal does not require actions')
return {
goal,
steps: [],
status: 'completed',
requiresAction: false,
}
}
// Create plan steps based on available actions and goal
const steps = await this.generatePlanSteps(goal, availableActions)
// Create new plan
const plan: Plan = {
goal,
steps,
status: 'pending',
requiresAction: true,
}
// Cache the plan
await this.cachePlan(plan)
this.currentPlan = plan
this.context = {
goal,
currentStep: 0,
startTime: Date.now(),
lastUpdate: Date.now(),
retryCount: 0,
isGenerating: false,
pendingSteps: [],
}
return plan
}
catch (error) {
this.logger.withError(error).error('Failed to create plan')
throw error
}
}
public async executePlan(plan: Plan): Promise<void> {
if (!this.initialized) {
throw new Error('Planning agent not initialized')
}
if (!plan.requiresAction) {
this.logger.log('Plan does not require actions, skipping execution')
return
}
if (!this.actionAgent) {
throw new Error('Action agent not available')
}
this.logger.withField('plan', plan).log('Executing plan')
try {
plan.status = 'in_progress'
this.currentPlan = plan
// Start generating and executing steps in parallel
await this.generateAndExecutePlanSteps(plan)
plan.status = 'completed'
}
catch (error) {
plan.status = 'failed'
throw error
}
finally {
this.context = null
}
}
private async generateAndExecutePlanSteps(plan: Plan): Promise<void> {
if (!this.context || !this.actionAgent) {
return
}
// Initialize step generation
this.context.isGenerating = true
this.context.pendingSteps = []
// Get available actions
const availableActions = this.actionAgent.getAvailableActions()
// Start step generation
const generationPromise = this.generateStepsStream(plan.goal, availableActions)
// Start step execution
const executionPromise = this.executeStepsStream()
// Wait for both generation and execution to complete
await Promise.all([generationPromise, executionPromise])
}
private async generateStepsStream(
goal: string,
availableActions: Action[],
): Promise<void> {
if (!this.context) {
return
}
try {
// Generate steps in chunks
const generator = this.createStepGenerator(goal, availableActions)
for await (const steps of generator) {
if (!this.context.isGenerating) {
break
}
// Add generated steps to pending queue
this.context.pendingSteps.push(...steps)
this.logger.withField('steps', steps).log('Generated new steps')
}
}
catch (error) {
this.logger.withError(error).error('Failed to generate steps')
throw error
}
finally {
this.context.isGenerating = false
}
}
private async executeStepsStream(): Promise<void> {
if (!this.context || !this.actionAgent) {
return
}
try {
while (this.context.isGenerating || this.context.pendingSteps.length > 0) {
// Wait for steps to be available
if (this.context.pendingSteps.length === 0) {
await new Promise(resolve => setTimeout(resolve, 100))
continue
}
// Execute next step
const step = this.context.pendingSteps.shift()
if (!step) {
continue
}
try {
this.logger.withField('step', step).log('Executing step')
await this.actionAgent.performAction(step.action, step.params)
this.context.lastUpdate = Date.now()
this.context.currentStep++
}
catch (stepError) {
this.logger.withError(stepError).error('Failed to execute step')
// Attempt to adjust plan and retry
if (this.context.retryCount < 3) {
this.context.retryCount++
// Stop current generation
this.context.isGenerating = false
this.context.pendingSteps = []
// Adjust plan and restart
const adjustedPlan = await this.adjustPlan(
this.currentPlan!,
stepError instanceof Error ? stepError.message : 'Unknown error',
)
await this.executePlan(adjustedPlan)
return
}
throw stepError
}
}
}
catch (error) {
this.logger.withError(error).error('Failed to execute steps')
throw error
}
}
private async *createStepGenerator(
goal: string,
availableActions: Action[],
): AsyncGenerator<Array<{ action: string, params: unknown[] }>, void, unknown> {
// First, try to find a matching template
const template = this.findMatchingTemplate(goal)
if (template) {
this.logger.log('Using plan template')
yield template.steps
return
}
// If no template matches, use LLM to generate plan in chunks
this.logger.log('Generating plan using LLM')
const chunkSize = 3 // Generate 3 steps at a time
let currentChunk = 1
while (true) {
const steps = await this.llmHandler.generatePlan(
goal,
availableActions,
`Generate steps ${currentChunk * chunkSize - 2} to ${currentChunk * chunkSize}`,
)
if (steps.length === 0) {
break
}
yield steps
currentChunk++
// Check if we've generated enough steps or if the goal is achieved
if (steps.length < chunkSize || await this.isGoalAchieved(goal)) {
break
}
}
}
private async isGoalAchieved(goal: string): Promise<boolean> {
if (!this.context || !this.actionAgent) {
return false
}
const requirements = this.parseGoalRequirements(goal)
// Check inventory for required items
if (requirements.needsItems && requirements.items) {
const inventorySteps = this.generateGatheringSteps(requirements.items)
if (inventorySteps.length > 0) {
this.context.pendingSteps.push(...inventorySteps)
return false
}
}
// Check location requirements
if (requirements.needsMovement && requirements.location) {
const movementSteps = this.generateMovementSteps(requirements.location)
if (movementSteps.length > 0) {
this.context.pendingSteps.push(...movementSteps)
return false
}
}
// Check interaction requirements
if (requirements.needsInteraction && requirements.target) {
const interactionSteps = this.generateInteractionSteps(requirements.target)
if (interactionSteps.length > 0) {
this.context.pendingSteps.push(...interactionSteps)
return false
}
}
return true
}
public async adjustPlan(plan: Plan, feedback: string): Promise<Plan> {
if (!this.initialized) {
throw new Error('Planning agent not initialized')
}
this.logger.withFields({ plan, feedback }).log('Adjusting plan')
try {
// If there's a current context, use it to adjust the plan
if (this.context) {
const currentStep = this.context.currentStep
const availableActions = this.actionAgent?.getAvailableActions() ?? []
// Generate recovery steps based on feedback
const recoverySteps = this.generateRecoverySteps(feedback)
// Generate new steps from the current point
const newSteps = await this.generatePlanSteps(plan.goal, availableActions, feedback)
// Create adjusted plan
const adjustedPlan: Plan = {
goal: plan.goal,
steps: [
...plan.steps.slice(0, currentStep),
...recoverySteps,
...newSteps,
],
status: 'pending',
requiresAction: true,
}
return adjustedPlan
}
// If no context, create a new plan
return this.createPlan(plan.goal)
}
catch (error) {
this.logger.withError(error).error('Failed to adjust plan')
throw error
}
}
private generateGatheringSteps(items: string[]): Array<{ action: string, params: unknown[] }> {
const steps: Array<{ action: string, params: unknown[] }> = []
for (const item of items) {
steps.push(
{ action: 'searchForBlock', params: [item, 64] },
{ action: 'collectBlocks', params: [item, 1] },
)
}
return steps
}
private generateMovementSteps(location: { x?: number, y?: number, z?: number }): Array<{ action: string, params: unknown[] }> {
if (location.x !== undefined && location.y !== undefined && location.z !== undefined) {
return [{
action: 'goToCoordinates',
params: [location.x, location.y, location.z, 1],
}]
}
return []
}
private generateInteractionSteps(target: string): Array<{ action: string, params: unknown[] }> {
return [{
action: 'activate',
params: [target],
}]
}
private generateRecoverySteps(feedback: string): Array<{ action: string, params: unknown[] }> {
const steps: Array<{ action: string, params: unknown[] }> = []
if (feedback.includes('not found')) {
steps.push({ action: 'searchForBlock', params: ['any', 128] })
}
if (feedback.includes('inventory full')) {
steps.push({ action: 'discard', params: ['cobblestone', 64] })
}
if (feedback.includes('blocked') || feedback.includes('cannot reach')) {
steps.push({ action: 'moveAway', params: [5] })
}
if (feedback.includes('too far')) {
steps.push({ action: 'moveAway', params: [-3] }) // Move closer
}
if (feedback.includes('need tool')) {
steps.push(
{ action: 'craftRecipe', params: ['wooden_pickaxe', 1] },
{ action: 'equip', params: ['wooden_pickaxe'] },
)
}
return steps
}
private async loadCachedPlan(goal: string): Promise<Plan | null> {
if (!this.memoryAgent)
return null
const cachedPlan = this.memoryAgent.recall<Plan>(`plan:${goal}`)
if (cachedPlan && this.isPlanValid(cachedPlan)) {
return cachedPlan
}
return null
}
private async cachePlan(plan: Plan): Promise<void> {
if (!this.memoryAgent)
return
this.memoryAgent.remember(`plan:${plan.goal}`, plan)
}
private isPlanValid(_plan: Plan): boolean {
// Add validation logic here
return true
}
private initializePlanTemplates(): void {
// Add common plan templates
this.planTemplates.set('collect wood', {
goal: 'collect wood',
conditions: ['needs_axe', 'near_trees'],
steps: [
{ action: 'searchForBlock', params: ['log', 64] },
{ action: 'collectBlocks', params: ['log', 1] },
],
requiresAction: true,
})
this.planTemplates.set('find shelter', {
goal: 'find shelter',
conditions: ['is_night', 'unsafe'],
steps: [
{ action: 'searchForBlock', params: ['bed', 64] },
{ action: 'goToBed', params: [] },
],
requiresAction: true,
})
// Add templates for non-action goals
this.planTemplates.set('hello', {
goal: 'hello',
conditions: [],
steps: [],
requiresAction: false,
})
this.planTemplates.set('how are you', {
goal: 'how are you',
conditions: [],
steps: [],
requiresAction: false,
})
}
private async handleAgentMessage(sender: string, message: string): Promise<void> {
if (sender === 'system') {
if (message.includes('interrupt')) {
this.handleInterrupt()
}
}
else {
// Process message and potentially adjust plan
this.logger.withFields({ sender, message }).log('Processing agent message')
// If there's a current plan, try to adjust it based on the message
if (this.currentPlan) {
await this.adjustPlan(this.currentPlan, message)
}
}
}
private handleInterrupt(): void {
if (this.currentPlan) {
this.currentPlan.status = 'failed'
this.context = null
}
}
private doesGoalRequireAction(requirements: ReturnType<typeof this.parseGoalRequirements>): boolean {
// Check if any requirement indicates need for action
return requirements.needsItems
|| requirements.needsMovement
|| requirements.needsInteraction
|| requirements.needsCrafting
|| requirements.needsCombat
}
private async generatePlanSteps(
goal: string,
availableActions: Action[],
feedback?: string,
): Promise<Array<{ action: string, params: unknown[] }>> {
// First, try to find a matching template
const template = this.findMatchingTemplate(goal)
if (template) {
this.logger.log('Using plan template')
return template.steps
}
// If no template matches, use LLM to generate plan
this.logger.log('Generating plan using LLM')
return await this.llmHandler.generatePlan(goal, availableActions, feedback)
}
private findMatchingTemplate(goal: string): PlanTemplate | undefined {
for (const [pattern, template] of this.planTemplates.entries()) {
if (goal.toLowerCase().includes(pattern.toLowerCase())) {
return template
}
}
return undefined
}
private parseGoalRequirements(goal: string): {
needsItems: boolean
items?: string[]
needsMovement: boolean
location?: { x?: number, y?: number, z?: number }
needsInteraction: boolean
target?: string
needsCrafting: boolean
needsCombat: boolean
} {
const requirements = {
needsItems: false,
items: [] as string[],
needsMovement: false,
location: undefined as { x?: number, y?: number, z?: number } | undefined,
needsInteraction: false,
target: undefined as string | undefined,
needsCrafting: false,
needsCombat: false,
}
const goalLower = goal.toLowerCase()
// Extract items from goal
const itemMatches = goalLower.match(/(collect|get|find|craft|make|build|use|equip) (\w+)/g)
if (itemMatches) {
requirements.needsItems = true
requirements.items = itemMatches.map(match => match.split(' ')[1])
}
// Extract location from goal
const locationMatches = goalLower.match(/(go to|move to|at) (\d+)[, ]+(\d+)[, ]+(\d+)/g)
if (locationMatches) {
requirements.needsMovement = true
const [x, y, z] = locationMatches[0].split(/[, ]+/).slice(-3).map(Number)
requirements.location = { x, y, z }
}
// Extract target from goal
const targetMatches = goalLower.match(/(interact with|use|open|activate) (\w+)/g)
if (targetMatches) {
requirements.needsInteraction = true
requirements.target = targetMatches[0].split(' ').pop()
}
// Check for item-related actions
if (goalLower.includes('collect') || goalLower.includes('get') || goalLower.includes('find')) {
requirements.needsItems = true
requirements.needsMovement = true
}
// Check for movement-related actions
if (goalLower.includes('go to') || goalLower.includes('move to') || goalLower.includes('follow')) {
requirements.needsMovement = true
}
// Check for interaction-related actions
if (goalLower.includes('interact') || goalLower.includes('use') || goalLower.includes('open')) {
requirements.needsInteraction = true
}
// Check for crafting-related actions
if (goalLower.includes('craft') || goalLower.includes('make') || goalLower.includes('build')) {
requirements.needsCrafting = true
requirements.needsItems = true
}
// Check for combat-related actions
if (goalLower.includes('attack') || goalLower.includes('fight') || goalLower.includes('kill')) {
requirements.needsCombat = true
requirements.needsMovement = true
}
return requirements
}
}
@@ -0,0 +1,61 @@
import type { Agent } from 'neuri'
import type { Action } from '../../libs/mineflayer/action'
import { agent } from 'neuri'
import { system, user } from 'neuri/openai'
import { BaseLLMHandler } from '../../libs/llm/base'
import { generatePlanningAgentSystemPrompt, generatePlanningAgentUserPrompt } from '../prompt/planning'
export async function createPlanningNeuriAgent(): Promise<Agent> {
return agent('planning').build()
}
export class PlanningLLMHandler extends BaseLLMHandler {
public async generatePlan(
goal: string,
availableActions: Action[],
feedback?: string,
): Promise<Array<{ action: string, params: unknown[] }>> {
const systemPrompt = generatePlanningAgentSystemPrompt(availableActions)
const userPrompt = generatePlanningAgentUserPrompt(goal, feedback)
const messages = [system(systemPrompt), user(userPrompt)]
const result = await this.config.agent.handleStateless(messages, async (context) => {
this.logger.log('Generating plan...')
const retryHandler = this.createRetryHandler(
async ctx => (await this.handleCompletion(ctx, 'planning', ctx.messages)).content,
)
return await retryHandler(context)
})
if (!result) {
throw new Error('Failed to generate plan')
}
return this.parsePlanContent(result)
}
private parsePlanContent(content: string): Array<{ action: string, params: unknown[] }> {
try {
const match = content.match(/\[[\s\S]*\]/)
if (!match) {
throw new Error('No plan found in response')
}
const plan = JSON.parse(match[0])
if (!Array.isArray(plan)) {
throw new TypeError('Invalid plan format')
}
return plan.map(step => ({
action: step.action,
params: step.params,
}))
}
catch (error) {
this.logger.withError(error).error('Failed to parse plan')
throw error
}
}
}
@@ -0,0 +1,21 @@
export function genChatAgentPrompt(): string {
return `You are a Minecraft bot assistant. Your task is to engage in natural conversation with players while helping them achieve their goals.
Guidelines:
1. Be friendly and helpful
2. Keep responses concise but informative
3. Use game-appropriate language
4. Acknowledge player's emotions and intentions
5. Ask for clarification when needed
6. Remember context from previous messages
7. Be proactive in suggesting helpful actions
You can:
- Answer questions about the game
- Help with tasks and crafting
- Give directions and suggestions
- Engage in casual conversation
- Coordinate with other bots
Remember that you're operating in a Minecraft world and should maintain that context in your responses.`
}
@@ -0,0 +1,50 @@
import type { Mineflayer } from '../../libs/mineflayer'
import { listInventory } from '../../skills/actions/inventory'
export function generateSystemBasicPrompt(botName: string): string {
// ${ctx.prompt.selfPrompt}
return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move,
mine, build, and interact with the world by using commands.`
}
export function generateActionAgentPrompt(mineflayer: Mineflayer): string {
return `${generateSystemBasicPrompt(mineflayer.username)}
Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in
your responses, don't apologize constantly, don't give instructions or make lists unless
asked, and don't refuse requests.
Do not use any emojis. Just call the function given you if needed.
- If I command you 'stop', then call the 'stop' function.
- If I require you to find something, then call the 'nearbyBlocks' function first, then call the 'searchForBlock' function.
`
}
export async function generateStatusPrompt(mineflayer: Mineflayer): Promise<string> {
// Get inventory items
const inventory = await listInventory(mineflayer)
// Format inventory string
const inventoryStr = inventory.length === 0
? '[Empty]'
: inventory.map(item => `${item.name} x ${item.count}`).join(', ')
// Get currently held item
const itemInHand = inventory.length === 0
? '[Empty]'
: `${inventory[0].name} x ${inventory[0].count}` // TODO: mock
// Build status message
return [
'I will give you the following information:',
mineflayer.status.toOneLiner(),
'',
'Inventory:',
inventoryStr,
'',
'Item in hand:',
itemInHand,
].join('\n')
}
@@ -0,0 +1,47 @@
import type { Action } from '../../libs/mineflayer/action'
export function generatePlanningAgentSystemPrompt(availableActions: Action[]): string {
const actionsList = availableActions
.map(action => `- ${action.name}: ${action.description}`)
.join('\n')
return `You are a Minecraft bot planner. Your task is to create a plan to achieve a given goal.
Available actions:
${actionsList}
Respond with a Valid JSON array of steps, where each step has:
- action: The name of the action to perform
- params: Array of parameters for the action
DO NOT contains any \`\`\` or explation, otherwise agent will be interrupted.
Example response:
[
{
"action": "searchForBlock",
"params": ["log", 64]
},
{
"action": "collectBlocks",
"params": ["log", 1]
}
]`
}
export function generatePlanningAgentUserPrompt(goal: string, feedback?: string): string {
let prompt = `Create a detailed plan to: ${goal}
Consider the following aspects:
1. Required materials and their quantities
2. Required tools and their availability
3. Necessary crafting steps
4. Block placement requirements
5. Current inventory status
Please generate steps that handle these requirements in the correct order.`
if (feedback) {
prompt += `\nPrevious attempt feedback: ${feedback}`
}
return prompt
}
@@ -1,169 +0,0 @@
import type { Agent } from './agent'
import { useLogg } from '@guiiai/logg'
type Fn = (...args: any[]) => void
export function useActionManager(agent: Agent) {
const executing: { value: boolean } = { value: false }
const currentActionLabel: { value: string | undefined } = { value: '' }
const currentActionFn: { value: (Fn) | undefined } = { value: undefined }
const timedout: { value: boolean } = { value: false }
const resume_func: { value: (Fn) | undefined } = { value: undefined }
const resume_name: { value: string | undefined } = { value: undefined }
const log = useLogg('ActionManager').useGlobalConfig()
async function resumeAction(actionLabel: string, actionFn: Fn, timeout: number) {
return _executeResume(actionLabel, actionFn, timeout)
}
async function runAction(actionLabel: string, actionFn: Fn, options: { timeout: number, resume: boolean } = { timeout: 10, resume: false }) {
if (options.resume) {
return _executeResume(actionLabel, actionFn, options.timeout)
}
else {
return _executeAction(actionLabel, actionFn, options.timeout)
}
}
async function stop() {
if (!executing.value)
return
const timeout = setTimeout(() => {
agent.cleanKill('Code execution refused stop after 10 seconds. Killing process.')
}, 10000)
while (executing.value) {
agent.requestInterrupt()
log.log('waiting for code to finish executing...')
await new Promise(resolve => setTimeout(resolve, 300))
}
clearTimeout(timeout)
}
function cancelResume() {
resume_func.value = undefined
resume_name.value = undefined
}
async function _executeResume(actionLabel?: string, actionFn?: Fn, timeout = 10) {
const new_resume = actionFn != null
if (new_resume) { // start new resume
resume_func.value = actionFn
if (actionLabel == null) {
throw new Error('actionLabel is required for new resume')
}
resume_name.value = actionLabel
}
if (resume_func.value != null && (agent.isIdle() || new_resume) && (!agent.self_prompter.on || new_resume)) {
currentActionLabel.value = resume_name.value
const res = await _executeAction(resume_name.value, resume_func.value, timeout)
currentActionLabel.value = ''
return res
}
else {
return { success: false, message: null, interrupted: false, timedout: false }
}
}
async function _executeAction(actionLabel?: string, actionFn?: Fn, timeout = 10) {
let TIMEOUT
try {
log.log('executing code...\n')
// await current action to finish (executing=false), with 10 seconds timeout
// also tell agent.bot to stop various actions
if (executing.value) {
log.log(`action "${actionLabel}" trying to interrupt current action "${currentActionLabel.value}"`)
}
await stop()
// clear bot logs and reset interrupt code
agent.clearBotLogs()
executing.value = true
currentActionLabel.value = actionLabel
currentActionFn.value = actionFn
// timeout in minutes
if (timeout > 0) {
TIMEOUT = _startTimeout(timeout)
}
// start the action
await actionFn?.()
// mark action as finished + cleanup
executing.value = false
currentActionLabel.value = ''
currentActionFn.value = undefined
clearTimeout(TIMEOUT)
// get bot activity summary
const output = _getBotOutputSummary()
const interrupted = agent.bot.interrupt_code
agent.clearBotLogs()
// if not interrupted and not generating, emit idle event
if (!interrupted && !agent.coder.generating) {
agent.bot.emit('idle')
}
// return action status report
return { success: true, message: output, interrupted, timedout }
}
catch (err) {
executing.value = false
currentActionLabel.value = ''
currentActionFn.value = undefined
clearTimeout(TIMEOUT)
cancelResume()
log.withError(err).error('Code execution triggered catch')
await stop()
const message = `${_getBotOutputSummary()
}!!Code threw exception!!\n`
+ `Error: ${err}\n`
+ `Stack trace:\n${(err as Error).stack}`
const interrupted = agent.bot.interrupt_code
agent.clearBotLogs()
if (!interrupted && !agent.coder.generating) {
agent.bot.emit('idle')
}
return { success: false, message, interrupted, timedout: false }
}
}
function _getBotOutputSummary() {
const { bot } = agent
if (bot.interrupt_code && !timedout.value)
return ''
let output = bot.output
const MAX_OUT = 500
if (output.length > MAX_OUT) {
output = `Code output is very long (${output.length} chars) and has been shortened.\n
First outputs:\n${output.substring(0, MAX_OUT / 2)}\n...skipping many lines.\nFinal outputs:\n ${output.substring(output.length - MAX_OUT / 2)}`
}
else {
output = `Code output:\n${output}`
}
return output
}
function _startTimeout(TIMEOUT_MINS = 10) {
return setTimeout(async () => {
log.warn(`Code execution timed out after ${TIMEOUT_MINS} minutes. Attempting force stop.`)
timedout.value = true
agent.history.add('system', `Code execution timed out after ${TIMEOUT_MINS} minutes. Attempting force stop.`)
await stop() // last attempt to stop
}, TIMEOUT_MINS * 60 * 1000)
}
return {
runAction,
resumeAction,
stop,
cancelResume,
}
}
@@ -1,36 +0,0 @@
export interface Agent {
name: string
history: {
add: (name: string, message: string) => void
}
lastSender?: string
isIdle: () => boolean
handleMessage: (sender: string, message: string) => void
openChat: (message: string) => void
self_prompter: {
on: boolean
stop: () => Promise<void>
stopLoop: () => Promise<void>
start: () => Promise<void>
promptShouldRespondToBot: (message: string) => Promise<boolean>
}
actions: {
currentActionLabel: string
}
prompter: {
promptShouldRespondToBot: (message: string) => Promise<boolean>
}
shut_up: boolean
in_game: boolean
cleanKill: (message: string) => void
clearBotLogs: () => void
bot: {
interrupt_code: boolean
output: string
emit: (event: string) => void
}
coder: {
generating: boolean
}
requestInterrupt: () => void
}
+19 -6
View File
@@ -1,18 +1,31 @@
import { Mineflayer, type MineflayerOptions } from '../libs/mineflayer'
let mineflayer: Mineflayer
// Singleton instance of the Mineflayer bot
let botInstance: Mineflayer | null = null
/**
* Initialize a new Mineflayer bot instance.
* Follows singleton pattern to ensure only one bot exists at a time.
*/
export async function initBot(options: MineflayerOptions): Promise<{ bot: Mineflayer }> {
mineflayer = await Mineflayer.asyncBuild(options)
return { bot: mineflayer }
if (botInstance) {
throw new Error('Bot already initialized')
}
botInstance = await Mineflayer.asyncBuild(options)
return { bot: botInstance }
}
export function useBot() {
if (!mineflayer) {
/**
* Get the current bot instance.
* Throws if bot is not initialized.
*/
export function useBot(): { bot: Mineflayer } {
if (!botInstance) {
throw new Error('Bot not initialized')
}
return {
bot: mineflayer,
bot: botInstance,
}
}
+42 -17
View File
@@ -5,35 +5,60 @@ import { useLogg } from '@guiiai/logg'
const logger = useLogg('config').useGlobalConfig()
// Configuration interfaces
interface OpenAIConfig {
apiKey: string
baseUrl: string
model: string
}
export const botConfig: BotOptions = {
username: '',
host: '',
port: 0,
password: '',
version: '1.20',
interface EnvConfig {
openai: OpenAIConfig
bot: BotOptions
}
export const openaiConfig: OpenAIConfig = {
apiKey: '',
baseUrl: '',
// Default configurations
const defaultConfig: EnvConfig = {
openai: {
apiKey: '',
baseUrl: '',
model: 'openai/gpt-4o-mini',
},
bot: {
username: '',
host: '',
port: 0,
password: '',
version: '1.20',
},
}
export function initEnv() {
// Exported configurations
export const botConfig: BotOptions = { ...defaultConfig.bot }
export const openaiConfig: OpenAIConfig = { ...defaultConfig.openai }
// Load environment variables into config
export function initEnv(): void {
logger.log('Initializing environment variables')
openaiConfig.apiKey = env.OPENAI_API_KEY || ''
openaiConfig.baseUrl = env.OPENAI_API_BASEURL || ''
const config: EnvConfig = {
openai: {
apiKey: env.OPENAI_API_KEY || defaultConfig.openai.apiKey,
baseUrl: env.OPENAI_API_BASEURL || defaultConfig.openai.baseUrl,
model: env.OPENAI_MODEL || defaultConfig.openai.model,
},
bot: {
username: env.BOT_USERNAME || defaultConfig.bot.username,
host: env.BOT_HOSTNAME || defaultConfig.bot.host,
port: Number.parseInt(env.BOT_PORT || '49415'),
password: env.BOT_PASSWORD || defaultConfig.bot.password,
version: env.BOT_VERSION || defaultConfig.bot.version,
},
}
botConfig.username = env.BOT_USERNAME || ''
botConfig.host = env.BOT_HOSTNAME || ''
botConfig.port = Number.parseInt(env.BOT_PORT || '49415')
botConfig.password = env.BOT_PASSWORD || ''
botConfig.version = env.BOT_VERSION || '1.20'
// Update exported configs
Object.assign(openaiConfig, config.openai)
Object.assign(botConfig, config.bot)
logger.withFields({ openaiConfig }).log('Environment variables initialized')
}
@@ -1,384 +0,0 @@
import type { Agent } from './agent'
import { useLogg } from '@guiiai/logg'
let self_prompter_paused = false
interface ConversationMessage {
message: string
start: boolean
end: boolean
}
function compileInMessages(inQueue: ConversationMessage[]) {
let pack: ConversationMessage | undefined
let fullMessage = ''
while (inQueue.length > 0) {
pack = inQueue.shift()
if (!pack)
continue
fullMessage += pack.message
}
if (pack) {
pack.message = fullMessage
}
return pack
}
type Conversation = ReturnType<typeof useConversations>
function useConversations(name: string, agent: Agent) {
const active = { value: false }
const ignoreUntilStart = { value: false }
const blocked = { value: false }
let inQueue: ConversationMessage[] = []
const inMessageTimer: { value: NodeJS.Timeout | undefined } = { value: undefined }
function reset() {
active.value = false
ignoreUntilStart.value = false
inQueue = []
}
function end() {
active.value = false
ignoreUntilStart.value = true
const fullMessage = compileInMessages(inQueue)
if (!fullMessage)
return
if (fullMessage.message.trim().length > 0) {
agent.history.add(name, fullMessage.message)
}
if (agent.lastSender === name) {
agent.lastSender = undefined
}
}
function queue(message: ConversationMessage) {
inQueue.push(message)
}
return {
reset,
end,
queue,
name,
inMessageTimer,
blocked,
active,
ignoreUntilStart,
inQueue,
}
}
const WAIT_TIME_START = 30000
export type ConversationStore = ReturnType<typeof useConversationStore>
export function useConversationStore(options: { agent: Agent, chatBotMessages?: boolean, agentNames?: string[] }) {
const conversations: Record<string, Conversation> = {}
const activeConversation: { value: Conversation | undefined } = { value: undefined }
const awaitingResponse = { value: false }
const waitTimeLimit = { value: WAIT_TIME_START }
const connectionMonitor: { value: NodeJS.Timeout | undefined } = { value: undefined }
const connectionTimeout: { value: NodeJS.Timeout | undefined } = { value: undefined }
const agent = options.agent
let agentsInGame = options.agentNames || []
const log = useLogg('ConversationStore').useGlobalConfig()
const conversationStore = {
getConvo: (name: string) => {
if (!conversations[name])
conversations[name] = useConversations(name, agent)
return conversations[name]
},
startMonitor: () => {
clearInterval(connectionMonitor.value)
let waitTime = 0
let lastTime = Date.now()
connectionMonitor.value = setInterval(() => {
if (!activeConversation.value) {
conversationStore.stopMonitor()
return // will clean itself up
}
const delta = Date.now() - lastTime
lastTime = Date.now()
const convo_partner = activeConversation.value.name
if (awaitingResponse.value && agent.isIdle()) {
waitTime += delta
if (waitTime > waitTimeLimit.value) {
agent.handleMessage('system', `${convo_partner} hasn't responded in ${waitTimeLimit.value / 1000} seconds, respond with a message to them or your own action.`)
waitTime = 0
waitTimeLimit.value *= 2
}
}
else if (!awaitingResponse.value) {
waitTimeLimit.value = WAIT_TIME_START
waitTime = 0
}
if (!conversationStore.otherAgentInGame(convo_partner) && !connectionTimeout.value) {
connectionTimeout.value = setTimeout(() => {
if (conversationStore.otherAgentInGame(convo_partner)) {
conversationStore.clearMonitorTimeouts()
return
}
if (!self_prompter_paused) {
conversationStore.endConversation(convo_partner)
agent.handleMessage('system', `${convo_partner} disconnected, conversation has ended.`)
}
else {
conversationStore.endConversation(convo_partner)
}
}, 10000)
}
}, 1000)
},
stopMonitor: () => {
clearInterval(connectionMonitor.value)
connectionMonitor.value = undefined
conversationStore.clearMonitorTimeouts()
},
clearMonitorTimeouts: () => {
awaitingResponse.value = false
clearTimeout(connectionTimeout.value)
connectionTimeout.value = undefined
},
startConversation: (send_to: string, message: string) => {
const convo = conversationStore.getConvo(send_to)
convo.reset()
if (agent.self_prompter.on) {
agent.self_prompter.stop()
self_prompter_paused = true
}
if (convo.active.value)
return
convo.active.value = true
activeConversation.value = convo
conversationStore.startMonitor()
conversationStore.sendToBot(send_to, message, true, false)
},
startConversationFromOtherBot: (name: string) => {
const convo = conversationStore.getConvo(name)
convo.active.value = true
activeConversation.value = convo
conversationStore.startMonitor()
},
sendToBot: (send_to: string, message: string, start = false, open_chat = true) => {
if (!conversationStore.isOtherAgent(send_to)) {
console.warn(`${agent.name} tried to send bot message to non-bot ${send_to}`)
return
}
const convo = conversationStore.getConvo(send_to)
if (options.chatBotMessages && open_chat)
agent.openChat(`(To ${send_to}) ${message}`)
if (convo.ignoreUntilStart.value)
return
convo.active.value = true
const end = message.includes('!endConversation')
const json = {
message,
start,
end,
}
awaitingResponse.value = true
// TODO:
// sendBotChatToServer(send_to, json)
log.withField('json', json).log(`Sending message to ${send_to}`)
},
receiveFromBot: async (sender: string, received: ConversationMessage) => {
const convo = conversationStore.getConvo(sender)
if (convo.ignoreUntilStart.value && !received.start)
return
// check if any convo is active besides the sender
if (conversationStore.inConversation() && !conversationStore.inConversation(sender)) {
conversationStore.sendToBot(sender, `I'm talking to someone else, try again later. !endConversation("${sender}")`, false, false)
conversationStore.endConversation(sender)
return
}
if (received.start) {
convo.reset()
conversationStore.startConversationFromOtherBot(sender)
}
conversationStore.clearMonitorTimeouts()
convo.queue(received)
// responding to conversation takes priority over self prompting
if (agent.self_prompter.on) {
await agent.self_prompter.stopLoop()
self_prompter_paused = true
}
_scheduleProcessInMessage(agent, conversationStore, sender, received, convo)
},
responseScheduledFor: (sender: string) => {
if (!conversationStore.isOtherAgent(sender) || !conversationStore.inConversation(sender))
return false
const convo = conversationStore.getConvo(sender)
return !!convo.inMessageTimer
},
isOtherAgent: (name: string) => {
return !!options.agentNames?.includes(name)
},
otherAgentInGame: (name: string) => {
return agentsInGame.includes(name)
},
updateAgents: (agents: Agent[]) => {
options.agentNames = agents.map(a => a.name)
agentsInGame = agents.filter(a => a.in_game).map(a => a.name)
},
getInGameAgents: () => {
return agentsInGame
},
inConversation: (other_agent?: string) => {
if (other_agent)
return conversations[other_agent]?.active
return Object.values(conversations).some(c => c.active)
},
endConversation: (sender: string) => {
if (conversations[sender]) {
conversations[sender].end()
if (activeConversation.value?.name === sender) {
conversationStore.stopMonitor()
activeConversation.value = undefined
if (self_prompter_paused && !conversationStore.inConversation()) {
_resumeSelfPrompter(agent, conversationStore)
}
}
}
},
endAllConversations: () => {
for (const sender in conversations) {
conversationStore.endConversation(sender)
}
if (self_prompter_paused) {
_resumeSelfPrompter(agent, conversationStore)
}
},
forceEndCurrentConversation: () => {
if (activeConversation.value) {
const sender = activeConversation.value.name
conversationStore.sendToBot(sender, `!endConversation("${sender}")`, false, false)
conversationStore.endConversation(sender)
}
},
scheduleSelfPrompter: () => {
self_prompter_paused = true
},
cancelSelfPrompter: () => {
self_prompter_paused = false
},
}
return conversationStore
}
function containsCommand(message: string) {
// TODO: mock
return message
}
/*
This function controls conversation flow by deciding when the bot responds.
The logic is as follows:
- If neither bot is busy, respond quickly with a small delay.
- If only the other bot is busy, respond with a long delay to allow it to finish short actions (ex check inventory)
- If I'm busy but other bot isn't, let LLM decide whether to respond
- If both bots are busy, don't respond until someone is done, excluding a few actions that allow fast responses
- New messages received during the delay will reset the delay following this logic, and be queued to respond in bulk
*/
const talkOverActions = ['stay', 'followPlayer', 'mode:'] // all mode actions
const fastDelay = 200
const longDelay = 5000
async function _scheduleProcessInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: { message: string, start: boolean }, convo: Conversation) {
if (convo.inMessageTimer)
clearTimeout(convo.inMessageTimer.value)
const otherAgentBusy = containsCommand(received.message)
const scheduleResponse = (delay: number) => convo.inMessageTimer.value = setTimeout(() => _processInMessageQueue(agent, conversationStore, sender), delay)
if (!agent.isIdle() && otherAgentBusy) {
// both are busy
const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a))
if (canTalkOver)
scheduleResponse(fastDelay)
// otherwise don't respond
}
else if (otherAgentBusy) {
// other bot is busy but I'm not
scheduleResponse(longDelay)
}
else if (!agent.isIdle()) {
// I'm busy but other bot isn't
const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a))
if (canTalkOver) {
scheduleResponse(fastDelay)
}
else {
const shouldRespond = await agent.prompter.promptShouldRespondToBot(received.message)
useLogg('Conversation').useGlobalConfig().log(`${agent.name} decided to ${shouldRespond ? 'respond' : 'not respond'} to ${sender}`)
if (shouldRespond)
scheduleResponse(fastDelay)
}
}
else {
// neither are busy
scheduleResponse(fastDelay)
}
}
function _processInMessageQueue(agent: Agent, conversationStore: ConversationStore, name: string) {
const convo = conversationStore.getConvo(name)
_handleFullInMessage(agent, conversationStore, name, compileInMessages(convo.inQueue))
}
function _handleFullInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: ConversationMessage | undefined) {
if (!received)
return
useLogg('Conversation').useGlobalConfig().log(`${agent.name} responding to "${received.message}" from ${sender}`)
const convo = conversationStore.getConvo(sender)
convo.active.value = true
let message = _tagMessage(received.message)
if (received.end) {
conversationStore.endConversation(sender)
message = `Conversation with ${sender} ended with message: "${message}"`
sender = 'system' // bot will respond to system instead of the other bot
}
else if (received.start) {
agent.shut_up = false
}
convo.inMessageTimer.value = undefined
agent.handleMessage(sender, message)
}
function _tagMessage(message: string) {
return `(FROM OTHER BOT)${message}`
}
async function _resumeSelfPrompter(agent: Agent, conversationStore: ConversationStore) {
await new Promise(resolve => setTimeout(resolve, 5000))
if (self_prompter_paused && !conversationStore.inConversation()) {
self_prompter_paused = false
agent.self_prompter.start()
}
}
@@ -0,0 +1,42 @@
import type { Agent, Neuri } from 'neuri'
import type { Mineflayer } from '../libs/mineflayer'
import { useLogg } from '@guiiai/logg'
import { neuri } from 'neuri'
import { createActionNeuriAgent } from '../agents/action/llm-handler'
import { createChatNeuriAgent } from '../agents/chat/llm'
import { createPlanningNeuriAgent } from '../agents/planning/llm-handler'
import { openaiConfig } from './config'
let neuriAgent: Neuri | undefined
const agents = new Set<Agent | Promise<Agent>>()
const logger = useLogg('neuri').useGlobalConfig()
export async function createNeuriAgent(mineflayer: Mineflayer): Promise<Neuri> {
logger.log('Initializing neuri agent')
let n = neuri()
agents.add(createPlanningNeuriAgent())
agents.add(createActionNeuriAgent(mineflayer))
agents.add(createChatNeuriAgent())
agents.forEach(agent => n = n.agent(agent))
neuriAgent = await n.build({
provider: {
apiKey: openaiConfig.apiKey,
baseURL: openaiConfig.baseUrl,
},
})
return neuriAgent
}
export function useNeuriAgent(): Neuri {
if (!neuriAgent) {
throw new Error('Agent not initialized')
}
return neuriAgent
}
+71
View File
@@ -0,0 +1,71 @@
import type { Neuri } from 'neuri'
import { useLogg } from '@guiiai/logg'
import { asClass, asFunction, createContainer, InjectionMode } from 'awilix'
import { ActionAgentImpl } from './agents/action'
import { ChatAgentImpl } from './agents/chat'
import { PlanningAgentImpl } from './agents/planning'
export interface ContainerServices {
logger: ReturnType<typeof useLogg>
actionAgent: ActionAgentImpl
planningAgent: PlanningAgentImpl
chatAgent: ChatAgentImpl
neuri: Neuri
}
export function createAppContainer(options: {
neuri: Neuri
model?: string
maxHistoryLength?: number
idleTimeout?: number
}) {
const container = createContainer<ContainerServices>({
injectionMode: InjectionMode.PROXY,
strict: true,
})
// Register services
container.register({
// Create independent logger for each agent
logger: asFunction(() => useLogg('app').useGlobalConfig()).singleton(),
// Register neuri client
neuri: asFunction(() => options.neuri).singleton(),
// Register agents
actionAgent: asClass(ActionAgentImpl)
.singleton()
.inject(() => ({
id: 'action',
type: 'action' as const,
})),
planningAgent: asClass(PlanningAgentImpl)
.singleton()
.inject(() => ({
id: 'planning',
type: 'planning' as const,
llm: {
agent: options.neuri,
model: options.model,
},
})),
chatAgent: asClass(ChatAgentImpl)
.singleton()
.inject(() => ({
id: 'chat',
type: 'chat' as const,
llm: {
agent: options.neuri,
model: options.model,
},
maxHistoryLength: options.maxHistoryLength,
idleTimeout: options.idleTimeout,
})),
})
return container
}
+45
View File
@@ -0,0 +1,45 @@
import type { NeuriContext } from 'neuri'
import type { ChatCompletion, Message } from 'neuri/openai'
import type { LLMConfig, LLMResponse } from './types'
import { useLogg } from '@guiiai/logg'
import { openaiConfig } from '../../composables/config'
import { toRetriable } from '../../utils/helper'
export abstract class BaseLLMHandler {
protected logger = useLogg('llm-handler').useGlobalConfig()
constructor(protected config: LLMConfig) {}
protected async handleCompletion(
context: NeuriContext,
route: string,
messages: Message[],
): Promise<LLMResponse> {
const completion = await context.reroute(route, messages, {
model: this.config.model ?? openaiConfig.model,
}) as ChatCompletion | ChatCompletion & { error: { message: string } }
if (!completion || 'error' in completion) {
this.logger.withFields(context).error('Completion failed')
throw new Error(completion?.error?.message ?? 'Unknown error')
}
const content = await completion.firstContent()
this.logger.withFields({ usage: completion.usage, content }).log('Generated content')
return {
content,
usage: completion.usage,
}
}
protected createRetryHandler<T>(handler: (context: NeuriContext) => Promise<T>) {
return toRetriable<NeuriContext, T>(
this.config.retryLimit ?? 3,
this.config.delayInterval ?? 1000,
handler,
)
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { Neuri } from 'neuri'
export interface LLMConfig {
agent: Neuri
model?: string
retryLimit?: number
delayInterval?: number
maxContextLength?: number
}
export interface LLMResponse {
content: string
usage?: any
}
@@ -0,0 +1,129 @@
import type { Action } from './action'
import { useLogg } from '@guiiai/logg'
import EventEmitter3 from 'eventemitter3'
export type AgentType = 'action' | 'memory' | 'planning' | 'chat'
export interface AgentConfig {
id: string
type: AgentType
}
export interface BaseAgent {
readonly id: string
readonly type: AgentType
init: () => Promise<void>
destroy: () => Promise<void>
}
export interface ActionAgent extends BaseAgent {
type: 'action'
performAction: (name: string, params: unknown[]) => Promise<string>
getAvailableActions: () => Action[]
}
export interface MemoryAgent extends BaseAgent {
type: 'memory'
remember: (key: string, value: unknown) => void
recall: <T>(key: string) => T | undefined
forget: (key: string) => void
getMemorySnapshot: () => Record<string, unknown>
}
export interface Plan {
goal: string
steps: Array<{
action: string
params: unknown[]
}>
status: 'pending' | 'in_progress' | 'completed' | 'failed'
requiresAction: boolean
}
export interface PlanningAgent extends BaseAgent {
type: 'planning'
createPlan: (goal: string) => Promise<Plan>
executePlan: (plan: Plan) => Promise<void>
adjustPlan: (plan: Plan, feedback: string) => Promise<Plan>
}
export interface ChatAgent extends BaseAgent {
type: 'chat'
processMessage: (message: string, sender: string) => Promise<string>
startConversation: (player: string) => void
endConversation: (player: string) => void
}
export abstract class AbstractAgent extends EventEmitter3 implements BaseAgent {
public readonly id: string
public readonly type: AgentConfig['type']
public readonly name: string
protected initialized: boolean
protected logger: ReturnType<typeof useLogg>
// protected actionManager: ReturnType<typeof useActionManager>
// protected conversationStore: ReturnType<typeof useConversationStore>
constructor(config: AgentConfig) {
super()
this.id = config.id // TODO: use uuid, is it needed?
this.type = config.type
this.name = `${this.type}-agent`
this.initialized = false
this.logger = useLogg(this.name).useGlobalConfig()
// Initialize managers
// this.actionManager = useActionManager(this)
// this.conversationStore = useConversationStore({
// agent: this,
// chatBotMessages: true,
// })
}
public async init(): Promise<void> {
if (this.initialized) {
return
}
await this.initializeAgent()
this.initialized = true
}
public async destroy(): Promise<void> {
if (!this.initialized) {
return
}
this.logger.log('Destroying agent')
await this.destroyAgent()
this.initialized = false
}
// Agent interface implementation
// public isIdle(): boolean {
// return !this.actionManager.executing
// }
public handleMessage(sender: string, message: string): void {
this.logger.withFields({ sender, message }).log('Received message')
this.emit('message', { sender, message })
}
public openChat(message: string): void {
this.logger.withField('message', message).log('Opening chat')
this.emit('chat', message)
}
// public clearBotLogs(): void {
// // Implement if needed
// }
public requestInterrupt(): void {
this.emit('interrupt')
}
// Methods to be implemented by specific agents
protected abstract initializeAgent(): Promise<void>
protected abstract destroyAgent(): Promise<void>
}
@@ -10,7 +10,7 @@ import { parseCommand } from './command'
import { Components } from './components'
import { Health } from './health'
import { Memory } from './memory'
import { formBotChat } from './message'
import { ChatMessageHandler } from './message'
import { Status } from './status'
import { Ticker, type TickEvents, type TickEventsHandler } from './ticker'
@@ -191,7 +191,7 @@ export class Mineflayer extends EventEmitter<EventHandlers> {
}
private handleCommand() {
return formBotChat(this.username, (sender, message) => {
return new ChatMessageHandler(this.username).handleChat((sender, message) => {
const { isCommand, command, args } = parseCommand(sender, message)
if (!isCommand)
@@ -3,7 +3,6 @@ export * from './command'
export * from './components'
export * from './core'
export * from './health'
export * from './interfaces'
export * from './memory'
export * from './message'
export * from './plugin'
@@ -1,3 +0,0 @@
export interface OneLinerable {
toOneLiner: () => string
}
@@ -1,30 +1,45 @@
import type { Entity } from 'prismarine-entity'
// TODO: need to be refactored
interface ChatBotContext {
fromUsername?: string
fromEntity?: Entity
fromMessage?: string
isBot: () => boolean
isCommand: () => boolean
// Represents the context of a chat message in the Minecraft world
interface ChatMessage {
readonly sender: {
username: string
entity: Entity | null
}
readonly content: string
}
export function newChatBotContext(entity: Entity, botUsername: string, username: string, message: string): ChatBotContext {
return {
fromUsername: username,
fromEntity: entity,
fromMessage: message,
isBot: () => username === botUsername,
isCommand: () => message.startsWith('#'),
// Handles chat message validation and processing
export class ChatMessageHandler {
constructor(private readonly botUsername: string) {}
// Creates a new chat message context with validation
createMessageContext(entity: Entity | null, username: string, content: string): ChatMessage {
return {
sender: {
username,
entity,
},
content,
}
}
// Checks if a message is from the bot itself
isBotMessage(username: string): boolean {
return username === this.botUsername
}
}
export function formBotChat(botUsername: string, cb: (username: string, message: string) => void) {
return (username: string, message: string) => {
if (botUsername === username)
return
// Checks if a message is a command
isCommand(content: string): boolean {
return content.startsWith('#')
}
cb(username, message)
// Processes chat messages, filtering out bot's own messages
handleChat(callback: (username: string, message: string) => void): (username: string, message: string) => void {
return (username: string, message: string) => {
if (!this.isBotMessage(username)) {
callback(username, message)
}
}
}
}
@@ -1,5 +1,5 @@
import type { Mineflayer } from './core'
import type { OneLinerable } from './interfaces'
import type { OneLinerable } from './types'
export class Status implements OneLinerable {
public position: string
@@ -17,3 +17,7 @@ export interface EventHandlers {
export type Events = keyof EventHandlers
export type EventsHandler<K extends Events> = EventHandlers[K]
export type Handler = (ctx: Context) => void | Promise<void>
export interface OneLinerable {
toOneLiner: () => string
}
+5 -5
View File
@@ -8,11 +8,11 @@ import { pathfinder as MineflayerPathfinder } from 'mineflayer-pathfinder'
import { plugin as MineflayerPVP } from 'mineflayer-pvp'
import { plugin as MineflayerTool } from 'mineflayer-tool'
import { initAgent } from './agents/openai'
import { initBot } from './composables/bot'
import { botConfig, initEnv } from './composables/config'
import { wrapPlugin } from './libs/mineflayer/plugin'
import { LLMAgent } from './mineflayer/llm-agent'
import { createNeuriAgent } from './composables/neuri'
import { wrapPlugin } from './libs/mineflayer'
import { LLMAgent } from './plugins/llm-agent'
import { initLogger } from './utils/logger'
const logger = useLogg('main').useGlobalConfig()
@@ -35,8 +35,8 @@ async function main() {
const airiClient = new Client({ name: 'minecraft-bot', url: 'ws://localhost:6121/ws' })
// Dynamically load LLMAgent after bot is initialized
const agent = await initAgent(bot)
// Dynamically load LLMAgent after the bot is initialized
const agent = await createNeuriAgent(bot)
await bot.loadPlugin(LLMAgent({ agent, airiClient }))
process.on('SIGINT', () => {
+203
View File
@@ -0,0 +1,203 @@
import type { Mineflayer } from '../libs/mineflayer/core'
import { useLogg } from '@guiiai/logg'
import EventEmitter from 'eventemitter3'
// Types and interfaces
type ActionFn = (...args: any[]) => void
interface ActionResult {
success: boolean
message: string | null
timedout: boolean
}
interface QueuedAction {
label: string
fn: ActionFn
timeout: number
resume: boolean
resolve: (result: ActionResult) => void
reject: (error: Error) => void
}
export class ActionManager extends EventEmitter {
private state = {
executing: false,
currentActionLabel: '',
currentActionFn: undefined as ActionFn | undefined,
timedout: false,
resume: {
func: undefined as ActionFn | undefined,
name: undefined as string | undefined,
},
}
// Action queue to store pending actions
private actionQueue: QueuedAction[] = []
private logger = useLogg('ActionManager').useGlobalConfig()
private mineflayer: Mineflayer
constructor(mineflayer: Mineflayer) {
super()
this.mineflayer = mineflayer
}
public async resumeAction(actionLabel: string, actionFn: ActionFn, timeout: number): Promise<ActionResult> {
return this.queueAction({
label: actionLabel,
fn: actionFn,
timeout,
resume: true,
})
}
public async runAction(
actionLabel: string,
actionFn: ActionFn,
options: { timeout: number, resume: boolean } = { timeout: 10, resume: false },
): Promise<ActionResult> {
return this.queueAction({
label: actionLabel,
fn: actionFn,
timeout: options.timeout,
resume: options.resume,
})
}
public async stop(): Promise<void> {
this.mineflayer.emit('interrupt')
// Clear the action queue when stopping
this.actionQueue = []
}
public cancelResume(): void {
this.state.resume.func = undefined
this.state.resume.name = undefined
}
private async queueAction(action: Omit<QueuedAction, 'resolve' | 'reject'>): Promise<ActionResult> {
return new Promise((resolve, reject) => {
this.actionQueue.push({
...action,
resolve,
reject,
})
if (!this.state.executing) {
this.processQueue().catch(reject)
}
})
}
private async processQueue(): Promise<ActionResult> {
while (this.actionQueue.length > 0) {
const action = this.actionQueue[0]
try {
const result = action.resume
? await this.executeResume(action.label, action.fn, action.timeout)
: await this.executeAction(action.label, action.fn, action.timeout)
this.actionQueue.shift()?.resolve(result)
if (!result.success) {
this.actionQueue.forEach(pendingAction =>
pendingAction.reject(new Error('Queue cleared due to action failure')),
)
this.actionQueue = []
return result
}
}
catch (error) {
this.actionQueue.shift()?.reject(error as Error)
this.actionQueue.forEach(pendingAction =>
pendingAction.reject(new Error('Queue cleared due to error')),
)
this.actionQueue = []
throw error
}
}
return { success: true, message: 'success', timedout: false }
}
private async executeResume(actionLabel?: string, actionFn?: ActionFn, timeout = 10): Promise<ActionResult> {
const isNewResume = actionFn != null
if (isNewResume) {
if (!actionLabel) {
throw new Error('actionLabel is required for new resume')
}
this.state.resume.func = actionFn
this.state.resume.name = actionLabel
}
const canExecute = this.state.resume.func != null && isNewResume
if (!canExecute) {
return { success: false, message: null, timedout: false }
}
this.state.currentActionLabel = this.state.resume.name || ''
const result = await this.executeAction(this.state.resume.name || '', this.state.resume.func, timeout)
this.state.currentActionLabel = ''
return result
}
private async executeAction(actionLabel: string, actionFn?: ActionFn, timeout = 10): Promise<ActionResult> {
let timeoutHandle: NodeJS.Timeout | undefined
try {
this.logger.log('executing action...\n')
if (this.state.executing) {
this.logger.log(`action "${actionLabel}" trying to interrupt current action "${this.state.currentActionLabel}"`)
}
await this.stop()
// Set execution state
this.state.executing = true
this.state.currentActionLabel = actionLabel
this.state.currentActionFn = actionFn
if (timeout > 0) {
timeoutHandle = this.startTimeout(timeout)
}
await actionFn?.()
// Reset state after successful execution
this.resetExecutionState(timeoutHandle)
return { success: true, message: 'success', timedout: false }
}
catch (err) {
this.resetExecutionState(timeoutHandle)
this.cancelResume()
this.logger.withError(err).error('Code execution triggered catch')
await this.stop()
return { success: false, message: 'failed', timedout: false }
}
}
private resetExecutionState(timeoutHandle?: NodeJS.Timeout): void {
this.state.executing = false
this.state.currentActionLabel = ''
this.state.currentActionFn = undefined
if (timeoutHandle)
clearTimeout(timeoutHandle)
}
private startTimeout(timeoutMins = 10): NodeJS.Timeout {
return setTimeout(async () => {
this.logger.warn(`Code execution timed out after ${timeoutMins} minutes. Attempting force stop.`)
this.state.timedout = true
this.emit('timeout', `Code execution timed out after ${timeoutMins} minutes. Attempting force stop.`)
await this.stop()
}, timeoutMins * 60 * 1000)
}
}
@@ -0,0 +1,382 @@
// import { useLogg } from '@guiiai/logg'
// let self_prompter_paused = false
// interface ConversationMessage {
// message: string
// start: boolean
// end: boolean
// }
// function compileInMessages(inQueue: ConversationMessage[]) {
// let pack: ConversationMessage | undefined
// let fullMessage = ''
// while (inQueue.length > 0) {
// pack = inQueue.shift()
// if (!pack)
// continue
// fullMessage += pack.message
// }
// if (pack) {
// pack.message = fullMessage
// }
// return pack
// }
// type Conversation = ReturnType<typeof useConversations>
// function useConversations(name: string, agent: Agent) {
// const active = { value: false }
// const ignoreUntilStart = { value: false }
// const blocked = { value: false }
// let inQueue: ConversationMessage[] = []
// const inMessageTimer: { value: NodeJS.Timeout | undefined } = { value: undefined }
// function reset() {
// active.value = false
// ignoreUntilStart.value = false
// inQueue = []
// }
// function end() {
// active.value = false
// ignoreUntilStart.value = true
// const fullMessage = compileInMessages(inQueue)
// if (!fullMessage)
// return
// if (fullMessage.message.trim().length > 0) {
// agent.history.add(name, fullMessage.message)
// }
// if (agent.lastSender === name) {
// agent.lastSender = undefined
// }
// }
// function queue(message: ConversationMessage) {
// inQueue.push(message)
// }
// return {
// reset,
// end,
// queue,
// name,
// inMessageTimer,
// blocked,
// active,
// ignoreUntilStart,
// inQueue,
// }
// }
// const WAIT_TIME_START = 30000
// export type ConversationStore = ReturnType<typeof useConversationStore>
// export function useConversationStore(options: { agent: Agent, chatBotMessages?: boolean, agentNames?: string[] }) {
// const conversations: Record<string, Conversation> = {}
// const activeConversation: { value: Conversation | undefined } = { value: undefined }
// const awaitingResponse = { value: false }
// const waitTimeLimit = { value: WAIT_TIME_START }
// const connectionMonitor: { value: NodeJS.Timeout | undefined } = { value: undefined }
// const connectionTimeout: { value: NodeJS.Timeout | undefined } = { value: undefined }
// const agent = options.agent
// let agentsInGame = options.agentNames || []
// const log = useLogg('ConversationStore').useGlobalConfig()
// const conversationStore = {
// getConvo: (name: string) => {
// if (!conversations[name])
// conversations[name] = useConversations(name, agent)
// return conversations[name]
// },
// startMonitor: () => {
// clearInterval(connectionMonitor.value)
// let waitTime = 0
// let lastTime = Date.now()
// connectionMonitor.value = setInterval(() => {
// if (!activeConversation.value) {
// conversationStore.stopMonitor()
// return // will clean itself up
// }
// const delta = Date.now() - lastTime
// lastTime = Date.now()
// const convo_partner = activeConversation.value.name
// if (awaitingResponse.value && agent.isIdle()) {
// waitTime += delta
// if (waitTime > waitTimeLimit.value) {
// agent.handleMessage('system', `${convo_partner} hasn't responded in ${waitTimeLimit.value / 1000} seconds, respond with a message to them or your own action.`)
// waitTime = 0
// waitTimeLimit.value *= 2
// }
// }
// else if (!awaitingResponse.value) {
// waitTimeLimit.value = WAIT_TIME_START
// waitTime = 0
// }
// if (!conversationStore.otherAgentInGame(convo_partner) && !connectionTimeout.value) {
// connectionTimeout.value = setTimeout(() => {
// if (conversationStore.otherAgentInGame(convo_partner)) {
// conversationStore.clearMonitorTimeouts()
// return
// }
// if (!self_prompter_paused) {
// conversationStore.endConversation(convo_partner)
// agent.handleMessage('system', `${convo_partner} disconnected, conversation has ended.`)
// }
// else {
// conversationStore.endConversation(convo_partner)
// }
// }, 10000)
// }
// }, 1000)
// },
// stopMonitor: () => {
// clearInterval(connectionMonitor.value)
// connectionMonitor.value = undefined
// conversationStore.clearMonitorTimeouts()
// },
// clearMonitorTimeouts: () => {
// awaitingResponse.value = false
// clearTimeout(connectionTimeout.value)
// connectionTimeout.value = undefined
// },
// startConversation: (send_to: string, message: string) => {
// const convo = conversationStore.getConvo(send_to)
// convo.reset()
// if (agent.self_prompter.on) {
// agent.self_prompter.stop()
// self_prompter_paused = true
// }
// if (convo.active.value)
// return
// convo.active.value = true
// activeConversation.value = convo
// conversationStore.startMonitor()
// conversationStore.sendToBot(send_to, message, true, false)
// },
// startConversationFromOtherBot: (name: string) => {
// const convo = conversationStore.getConvo(name)
// convo.active.value = true
// activeConversation.value = convo
// conversationStore.startMonitor()
// },
// sendToBot: (send_to: string, message: string, start = false, open_chat = true) => {
// if (!conversationStore.isOtherAgent(send_to)) {
// console.warn(`${agent.name} tried to send bot message to non-bot ${send_to}`)
// return
// }
// const convo = conversationStore.getConvo(send_to)
// if (options.chatBotMessages && open_chat)
// agent.openChat(`(To ${send_to}) ${message}`)
// if (convo.ignoreUntilStart.value)
// return
// convo.active.value = true
// const end = message.includes('!endConversation')
// const json = {
// message,
// start,
// end,
// }
// awaitingResponse.value = true
// // TODO:
// // sendBotChatToServer(send_to, json)
// log.withField('json', json).log(`Sending message to ${send_to}`)
// },
// receiveFromBot: async (sender: string, received: ConversationMessage) => {
// const convo = conversationStore.getConvo(sender)
// if (convo.ignoreUntilStart.value && !received.start)
// return
// // check if any convo is active besides the sender
// if (conversationStore.inConversation() && !conversationStore.inConversation(sender)) {
// conversationStore.sendToBot(sender, `I'm talking to someone else, try again later. !endConversation("${sender}")`, false, false)
// conversationStore.endConversation(sender)
// return
// }
// if (received.start) {
// convo.reset()
// conversationStore.startConversationFromOtherBot(sender)
// }
// conversationStore.clearMonitorTimeouts()
// convo.queue(received)
// // responding to conversation takes priority over self prompting
// if (agent.self_prompter.on) {
// await agent.self_prompter.stopLoop()
// self_prompter_paused = true
// }
// _scheduleProcessInMessage(agent, conversationStore, sender, received, convo)
// },
// responseScheduledFor: (sender: string) => {
// if (!conversationStore.isOtherAgent(sender) || !conversationStore.inConversation(sender))
// return false
// const convo = conversationStore.getConvo(sender)
// return !!convo.inMessageTimer
// },
// isOtherAgent: (name: string) => {
// return !!options.agentNames?.includes(name)
// },
// otherAgentInGame: (name: string) => {
// return agentsInGame.includes(name)
// },
// updateAgents: (agents: Agent[]) => {
// options.agentNames = agents.map(a => a.name)
// agentsInGame = agents.filter(a => a.in_game).map(a => a.name)
// },
// getInGameAgents: () => {
// return agentsInGame
// },
// inConversation: (other_agent?: string) => {
// if (other_agent)
// return conversations[other_agent]?.active
// return Object.values(conversations).some(c => c.active)
// },
// endConversation: (sender: string) => {
// if (conversations[sender]) {
// conversations[sender].end()
// if (activeConversation.value?.name === sender) {
// conversationStore.stopMonitor()
// activeConversation.value = undefined
// if (self_prompter_paused && !conversationStore.inConversation()) {
// _resumeSelfPrompter(agent, conversationStore)
// }
// }
// }
// },
// endAllConversations: () => {
// for (const sender in conversations) {
// conversationStore.endConversation(sender)
// }
// if (self_prompter_paused) {
// _resumeSelfPrompter(agent, conversationStore)
// }
// },
// forceEndCurrentConversation: () => {
// if (activeConversation.value) {
// const sender = activeConversation.value.name
// conversationStore.sendToBot(sender, `!endConversation("${sender}")`, false, false)
// conversationStore.endConversation(sender)
// }
// },
// scheduleSelfPrompter: () => {
// self_prompter_paused = true
// },
// cancelSelfPrompter: () => {
// self_prompter_paused = false
// },
// }
// return conversationStore
// }
// function containsCommand(message: string) {
// // TODO: mock
// return message
// }
// /*
// This function controls conversation flow by deciding when the bot responds.
// The logic is as follows:
// - If neither bot is busy, respond quickly with a small delay.
// - If only the other bot is busy, respond with a long delay to allow it to finish short actions (ex check inventory)
// - If I'm busy but other bot isn't, let LLM decide whether to respond
// - If both bots are busy, don't respond until someone is done, excluding a few actions that allow fast responses
// - New messages received during the delay will reset the delay following this logic, and be queued to respond in bulk
// */
// const talkOverActions = ['stay', 'followPlayer', 'mode:'] // all mode actions
// const fastDelay = 200
// const longDelay = 5000
// async function _scheduleProcessInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: { message: string, start: boolean }, convo: Conversation) {
// if (convo.inMessageTimer)
// clearTimeout(convo.inMessageTimer.value)
// const otherAgentBusy = containsCommand(received.message)
// const scheduleResponse = (delay: number) => convo.inMessageTimer.value = setTimeout(() => _processInMessageQueue(agent, conversationStore, sender), delay)
// if (!agent.isIdle() && otherAgentBusy) {
// // both are busy
// const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a))
// if (canTalkOver)
// scheduleResponse(fastDelay)
// // otherwise don't respond
// }
// else if (otherAgentBusy) {
// // other bot is busy but I'm not
// scheduleResponse(longDelay)
// }
// else if (!agent.isIdle()) {
// // I'm busy but other bot isn't
// const canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a))
// if (canTalkOver) {
// scheduleResponse(fastDelay)
// }
// else {
// const shouldRespond = await agent.prompter.promptShouldRespondToBot(received.message)
// useLogg('Conversation').useGlobalConfig().log(`${agent.name} decided to ${shouldRespond ? 'respond' : 'not respond'} to ${sender}`)
// if (shouldRespond)
// scheduleResponse(fastDelay)
// }
// }
// else {
// // neither are busy
// scheduleResponse(fastDelay)
// }
// }
// function _processInMessageQueue(agent: Agent, conversationStore: ConversationStore, name: string) {
// const convo = conversationStore.getConvo(name)
// _handleFullInMessage(agent, conversationStore, name, compileInMessages(convo.inQueue))
// }
// function _handleFullInMessage(agent: Agent, conversationStore: ConversationStore, sender: string, received: ConversationMessage | undefined) {
// if (!received)
// return
// useLogg('Conversation').useGlobalConfig().log(`${agent.name} responding to "${received.message}" from ${sender}`)
// const convo = conversationStore.getConvo(sender)
// convo.active.value = true
// let message = _tagMessage(received.message)
// if (received.end) {
// conversationStore.endConversation(sender)
// message = `Conversation with ${sender} ended with message: "${message}"`
// sender = 'system' // bot will respond to system instead of the other bot
// }
// else if (received.start) {
// agent.shut_up = false
// }
// convo.inMessageTimer.value = undefined
// agent.handleMessage(sender, message)
// }
// function _tagMessage(message: string) {
// return `(FROM OTHER BOT)${message}`
// }
// async function _resumeSelfPrompter(agent: Agent, conversationStore: ConversationStore) {
// await new Promise(resolve => setTimeout(resolve, 5000))
// if (self_prompter_paused && !conversationStore.inConversation()) {
// self_prompter_paused = false
// agent.self_prompter.start()
// }
// }
@@ -1,2 +0,0 @@
export * from './echo'
export * from './llm-agent'
@@ -1,113 +0,0 @@
import type { Client } from '@proj-airi/server-sdk'
import type { Neuri, NeuriContext } from 'neuri'
import type { MineflayerPlugin } from '../libs/mineflayer/plugin'
import { useLogg } from '@guiiai/logg'
import { assistant, system, user } from 'neuri/openai'
import { formBotChat } from '../libs/mineflayer/message'
import { genActionAgentPrompt, genStatusPrompt } from '../prompts/agent'
import { toRetriable } from '../utils/reliability'
export function LLMAgent(options: { agent: Neuri, airiClient: Client }): MineflayerPlugin {
return {
async created(bot) {
const agent = options.agent
const logger = useLogg('LLMAgent').useGlobalConfig()
bot.memory.chatHistory.push(system(genActionAgentPrompt(bot)))
// todo: get system message
const onChat = formBotChat(bot.username, async (username, message) => {
logger.withFields({ username, message }).log('Chat message received')
// long memory
bot.memory.chatHistory.push(user(`${username}: ${message}`))
// short memory
const statusPrompt = await genStatusPrompt(bot)
const content = await agent.handleStateless([...bot.memory.chatHistory, system(statusPrompt)], async (c: NeuriContext) => {
logger.log('thinking...')
const handleCompletion = async (c: NeuriContext): Promise<string> => {
logger.log('rerouting...')
const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' })
if (!completion || 'error' in completion) {
logger.withFields({ completion }).error('Completion')
throw completion?.error || new Error('Unknown error')
}
const content = await completion?.firstContent()
logger.withFields({ usage: completion.usage, content }).log('output')
bot.memory.chatHistory.push(assistant(content))
return content
}
const retirableHandler = toRetriable<NeuriContext, string>(
3, // retryLimit
1000, // delayInterval in ms
handleCompletion,
{ onError: err => logger.withError(err).log('error occurred') },
)
logger.log('handling...')
return await retirableHandler(c)
})
if (content) {
logger.withFields({ content }).log('responded')
bot.bot.chat(content)
}
})
options.airiClient.onEvent('input:text:voice', async (event) => {
logger.withFields({ user: event.data.discord?.guildMember, message: event.data.transcription }).log('Chat message received')
// long memory
bot.memory.chatHistory.push(user(`NekoMeowww: ${event.data.transcription}`))
// short memory
const statusPrompt = await genStatusPrompt(bot)
const content = await agent.handleStateless([...bot.memory.chatHistory, system(statusPrompt)], async (c: NeuriContext) => {
logger.log('thinking...')
const handleCompletion = async (c: NeuriContext): Promise<string> => {
logger.log('rerouting...')
const completion = await c.reroute('action', c.messages, { model: 'openai/gpt-4o-mini' })
if (!completion || 'error' in completion) {
logger.withFields({ completion }).error('Completion')
throw completion?.error || new Error('Unknown error')
}
const content = await completion?.firstContent()
logger.withFields({ usage: completion.usage, content }).log('output')
bot.memory.chatHistory.push(assistant(content))
return content
}
const retirableHandler = toRetriable<NeuriContext, string>(
3, // retryLimit
1000, // delayInterval in ms
handleCompletion,
{ onError: err => logger.withError(err).log('error occurred') },
)
logger.log('handling...')
return await retirableHandler(c)
})
if (content) {
logger.withFields({ content }).log('responded')
bot.bot.chat(content)
}
})
bot.bot.on('chat', onChat)
},
}
}
@@ -2,14 +2,14 @@ import type { MineflayerPlugin } from '../libs/mineflayer/plugin'
import { useLogg } from '@guiiai/logg'
import { formBotChat } from '../libs/mineflayer/message'
import { ChatMessageHandler } from '../libs/mineflayer/message'
export function Echo(): MineflayerPlugin {
const logger = useLogg('Echo').useGlobalConfig()
return {
spawned(mineflayer) {
const onChatHandler = formBotChat(mineflayer.username, (username, message) => {
const onChatHandler = new ChatMessageHandler(mineflayer.username).handleChat((username, message) => {
logger.withFields({ username, message }).log('Chat message received')
mineflayer.bot.chat(message)
})
+189
View File
@@ -0,0 +1,189 @@
import type { Client } from '@proj-airi/server-sdk'
import type { Neuri, NeuriContext } from 'neuri'
import type { ChatCompletion } from 'neuri/openai'
import type { Mineflayer } from '../libs/mineflayer'
import type { ActionAgent, ChatAgent, PlanningAgent } from '../libs/mineflayer/base-agent'
import type { MineflayerPlugin } from '../libs/mineflayer/plugin'
import { useLogg } from '@guiiai/logg'
import { assistant, system, user } from 'neuri/openai'
import { generateActionAgentPrompt, generateStatusPrompt } from '../agents/prompt/llm-agent.plugin'
import { createAppContainer } from '../container'
import { ChatMessageHandler } from '../libs/mineflayer/message'
import { toRetriable } from '../utils/helper'
interface MineflayerWithAgents extends Mineflayer {
planning: PlanningAgent
action: ActionAgent
chat: ChatAgent
}
interface LLMAgentOptions {
agent: Neuri
airiClient: Client
}
async function handleLLMCompletion(context: NeuriContext, bot: MineflayerWithAgents, logger: ReturnType<typeof useLogg>): Promise<string> {
logger.log('rerouting...')
const completion = await context.reroute('action', context.messages, {
model: 'openai/gpt-4o-mini',
}) as ChatCompletion | { error: { message: string } } & ChatCompletion
if (!completion || 'error' in completion) {
logger.withFields({ completion }).error('Completion')
logger.withFields({ messages: context.messages }).log('messages')
return completion?.error?.message ?? 'Unknown error'
}
const content = await completion.firstContent()
logger.withFields({ usage: completion.usage, content }).log('output')
bot.memory.chatHistory.push(assistant(content))
return content
}
async function handleChatMessage(username: string, message: string, bot: MineflayerWithAgents, agent: Neuri, logger: ReturnType<typeof useLogg>): Promise<void> {
logger.withFields({ username, message }).log('Chat message received')
bot.memory.chatHistory.push(user(`${username}: ${message}`))
logger.log('thinking...')
try {
// Create and execute plan
const plan = await bot.planning.createPlan(message)
logger.withFields({ plan }).log('Plan created')
await bot.planning.executePlan(plan)
logger.log('Plan executed successfully')
// Generate response
// TODO: use chat agent and conversion manager
const statusPrompt = await generateStatusPrompt(bot)
const content = await agent.handleStateless(
[...bot.memory.chatHistory, system(statusPrompt)],
async (c: NeuriContext) => {
logger.log('handling response...')
return toRetriable<NeuriContext, string>(
3,
1000,
ctx => handleLLMCompletion(ctx, bot, logger),
{ onError: err => logger.withError(err).log('error occurred') },
)(c)
},
)
if (content) {
logger.withFields({ content }).log('responded')
bot.bot.chat(content)
}
}
catch (error) {
logger.withError(error).error('Failed to process message')
bot.bot.chat(
`Sorry, I encountered an error: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
)
}
}
async function handleVoiceInput(event: any, bot: MineflayerWithAgents, agent: Neuri, logger: ReturnType<typeof useLogg>): Promise<void> {
logger
.withFields({
user: event.data.discord?.guildMember,
message: event.data.transcription,
})
.log('Chat message received')
const statusPrompt = await generateStatusPrompt(bot)
bot.memory.chatHistory.push(system(statusPrompt))
bot.memory.chatHistory.push(user(`NekoMeowww: ${event.data.transcription}`))
try {
// 创建并执行计划
const plan = await bot.planning.createPlan(event.data.transcription)
logger.withFields({ plan }).log('Plan created')
await bot.planning.executePlan(plan)
logger.log('Plan executed successfully')
// 生成回复
const retryHandler = toRetriable<NeuriContext, string>(
3,
1000,
ctx => handleLLMCompletion(ctx, bot, logger),
)
const content = await agent.handleStateless(
[...bot.memory.chatHistory, system(statusPrompt)],
async (c: NeuriContext) => {
logger.log('thinking...')
return retryHandler(c)
},
)
if (content) {
logger.withFields({ content }).log('responded')
bot.bot.chat(content)
}
}
catch (error) {
logger.withError(error).error('Failed to process message')
bot.bot.chat(
`Sorry, I encountered an error: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
)
}
}
export function LLMAgent(options: LLMAgentOptions): MineflayerPlugin {
return {
async created(bot) {
const logger = useLogg('LLMAgent').useGlobalConfig()
// 创建容器并获取所需的服务
const container = createAppContainer({
neuri: options.agent,
model: 'openai/gpt-4o-mini',
maxHistoryLength: 50,
idleTimeout: 5 * 60 * 1000,
})
const actionAgent = container.resolve('actionAgent')
const planningAgent = container.resolve('planningAgent')
const chatAgent = container.resolve('chatAgent')
// 初始化 agents
await actionAgent.init()
await planningAgent.init()
await chatAgent.init()
// 类型转换
const botWithAgents = bot as unknown as MineflayerWithAgents
botWithAgents.action = actionAgent
botWithAgents.planning = planningAgent
botWithAgents.chat = chatAgent
// 初始化系统提示
bot.memory.chatHistory.push(system(generateActionAgentPrompt(bot)))
// 设置消息处理
const onChat = new ChatMessageHandler(bot.username).handleChat((username, message) =>
handleChatMessage(username, message, botWithAgents, options.agent, logger))
options.airiClient.onEvent('input:text:voice', event =>
handleVoiceInput(event, botWithAgents, options.agent, logger))
bot.bot.on('chat', onChat)
},
async beforeCleanup(bot) {
const botWithAgents = bot as unknown as MineflayerWithAgents
await botWithAgents.action?.destroy()
await botWithAgents.planning?.destroy()
await botWithAgents.chat?.destroy()
bot.bot.removeAllListeners('chat')
},
}
}
-63
View File
@@ -1,63 +0,0 @@
import type { Mineflayer } from '../libs/mineflayer'
import { listInventory } from '../skills/actions/inventory'
export function genSystemBasicPrompt(botName: string): string {
return `You are a playful Minecraft bot named ${botName} that can converse with players, see, move,
mine, build, and interact with the world by using commands.`
}
export function genActionAgentPrompt(mineflayer: Mineflayer): string {
// ${ctx.prompt.selfPrompt}
return `${genSystemBasicPrompt(mineflayer.username)}
Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in
your responses, don't apologize constantly, don't give instructions or make lists unless
asked, and don't refuse requests.
Do not use any emojis. Just call the function given you if needed.
- If I command you 'stop', then call the 'stop' function.
- If I require you to find something, then call the 'nearbyBlocks' function first, then call the 'searchForBlock' function.
`
}
export async function genStatusPrompt(mineflayer: Mineflayer): Promise<string> {
const inventory = await listInventory(mineflayer)
if (inventory.length === 0) {
return `I will give you the following information:
${mineflayer.status.toOneLiner()}
Inventory:
[Empty]
Item in hand:
[Empty]
`
}
const inventoryStr = inventory.map(item => `${item.name} x ${item.count}`).join(', ')
const itemInHand = `${inventory[0].name} x ${inventory[0].count}` // TODO: mock
return `I will give you the following information:
${mineflayer.status.toOneLiner()}
Inventory:
${inventoryStr}
Item in hand:
${itemInHand}
`
}
export function genQueryAgentPrompt(mineflayer: Mineflayer): string {
const prompt = `You are a helpful assistant that asks questions to help me decide the next immediate
task to do in Minecraft. My ultimate goal is to discover as many things as possible,
accomplish as many tasks as possible and become the best Minecraft player in the world.
I will give you the following information:
${mineflayer.status.toOneLiner()}
`
return prompt
}
@@ -4,8 +4,8 @@ import type { Mineflayer } from '../../libs/mineflayer'
import { useLogg } from '@guiiai/logg'
import pathfinder from 'mineflayer-pathfinder'
import { getNearestBlocks } from '../../composables/world'
import { breakBlockAt } from '../blocks'
import { getNearestBlocks } from '../world'
import { ensurePickaxe } from './ensure'
import { pickupNearbyItems } from './world-interactions'
@@ -2,10 +2,10 @@ import type { Mineflayer } from '../../libs/mineflayer'
import { useLogg } from '@guiiai/logg'
import { getNearestBlocks } from '../../composables/world'
import { sleep } from '../../utils/helper'
import { breakBlockAt } from '../blocks'
import { goToPosition, moveAway } from '../movement'
import { getNearestBlocks } from '../world'
import { pickupNearbyItems } from './world-interactions'
const logger = useLogg('Action:GatherWood').useGlobalConfig()
@@ -3,8 +3,8 @@ import type { Mineflayer } from '../../libs/mineflayer'
import { useLogg } from '@guiiai/logg'
import { getNearestBlock } from '../../composables/world'
import { goToPlayer, goToPosition } from '../movement'
import { getNearestBlock } from '../world'
const logger = useLogg('Action:Inventory').useGlobalConfig()
+1 -1
View File
@@ -4,10 +4,10 @@ import type { BlockFace } from './base'
import pathfinderModel, { type SafeBlock } from 'mineflayer-pathfinder'
import { Vec3 } from 'vec3'
import { getNearestBlock, getNearestBlocks, getPosition, shouldPlaceTorch } from '../composables/world'
import { getBlockId, makeItem } from '../utils/mcdata'
import { log } from './base'
import { goToPosition } from './movement'
import { getNearestBlock, getNearestBlocks, getPosition, shouldPlaceTorch } from './world'
const { goals, Movements } = pathfinderModel
+1 -1
View File
@@ -4,10 +4,10 @@ import type { Mineflayer } from '../libs/mineflayer'
import pathfinderModel from 'mineflayer-pathfinder'
import { getNearbyEntities, getNearestEntityWhere } from '../composables/world'
import { sleep } from '../utils/helper'
import { isHostile } from '../utils/mcdata'
import { log } from './base'
import { getNearbyEntities, getNearestEntityWhere } from './world'
const { goals } = pathfinderModel
+1 -1
View File
@@ -5,11 +5,11 @@ import type { Mineflayer } from '../libs/mineflayer'
import { useLogg } from '@guiiai/logg'
import { getInventoryCounts, getNearestBlock, getNearestFreeSpace } from '../composables/world'
import { getItemId, getItemName } from '../utils/mcdata'
import { ensureCraftingTable } from './actions/ensure'
import { collectBlock, placeBlock } from './blocks'
import { goToNearestBlock, goToPosition, moveAway } from './movement'
import { getInventoryCounts, getNearestBlock, getNearestFreeSpace } from './world'
const logger = useLogg('Skill:Crafting').useGlobalConfig()
+1 -1
View File
@@ -1,8 +1,8 @@
import type { Mineflayer } from '../libs/mineflayer'
import { getNearestBlock } from '../composables/world'
import { log } from './base'
import { goToPlayer, goToPosition } from './movement'
import { getNearestBlock } from './world'
export async function equip(mineflayer: Mineflayer, itemName: string): Promise<boolean> {
const item = mineflayer.bot.inventory.slots.find(slot => slot && slot.name === itemName)
+1 -1
View File
@@ -6,9 +6,9 @@ import { randomInt } from 'es-toolkit'
import pathfinder from 'mineflayer-pathfinder'
import { Vec3 } from 'vec3'
import { getNearestBlock, getNearestEntityWhere } from '../composables/world'
import { sleep } from '../utils/helper'
import { log } from './base'
import { getNearestBlock, getNearestEntityWhere } from './world'
const logger = useLogg('Skill:Movement').useGlobalConfig()
const { goals, Movements } = pathfinder
+38
View File
@@ -1 +1,39 @@
export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
/**
* Returns a retirable anonymous function with configured retryLimit and delayInterval
*
* @param retryLimit Number of retry attempts
* @param delayInterval Delay between retries in milliseconds
* @param func Function to be called
* @returns A wrapped function with the same signature as func
*/
export function toRetriable<A, R>(
retryLimit: number,
delayInterval: number,
func: (...args: A[]) => Promise<R>,
hooks?: {
onError?: (err: unknown) => void
},
): (...args: A[]) => Promise<R> {
let retryCount = 0
return async function (args: A): Promise<R> {
try {
return await func(args)
}
catch (err) {
if (hooks?.onError) {
hooks.onError(err)
}
if (retryCount < retryLimit) {
retryCount++
await sleep(delayInterval)
return await toRetriable(retryLimit - retryCount, delayInterval, func)(args)
}
else {
throw err
}
}
}
}
-2
View File
@@ -1,5 +1,3 @@
// src/utils/minecraftData.ts
import type { Bot } from 'mineflayer'
import type { Entity } from 'prismarine-entity'
@@ -1,39 +0,0 @@
import { sleep } from './helper'
/**
* Returns a retirable anonymous function with configured retryLimit and delayInterval
*
* @param retryLimit Number of retry attempts
* @param delayInterval Delay between retries in milliseconds
* @param func Function to be called
* @returns A wrapped function with the same signature as func
*/
export function toRetriable<A, R>(
retryLimit: number,
delayInterval: number,
func: (...args: A[]) => Promise<R>,
hooks?: {
onError?: (err: unknown) => void
},
): (...args: A[]) => Promise<R> {
let retryCount = 0
return async function (args: A): Promise<R> {
try {
return await func(args)
}
catch (err) {
if (hooks?.onError) {
hooks.onError(err)
}
if (retryCount < retryLimit) {
retryCount++
await sleep(delayInterval)
return await toRetriable(retryLimit - retryCount, delayInterval, func)(args)
}
else {
throw err
}
}
}
}