feat: command

This commit is contained in:
RainbowBird
2025-01-06 18:32:42 +08:00
parent 5dbf7669ba
commit 337117fccb
16 changed files with 580 additions and 78 deletions
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { initQueryAgent } from './openai'
describe('openAI agent', () => {
it('should initialize the agent', () => {
const agent = initQueryAgent()
expect(agent).toBeDefined()
})
})
+74
View File
@@ -0,0 +1,74 @@
import type { BotContext } from 'src/bot'
import { useLogg } from '@guiiai/logg'
import { tool } from 'xsai'
import { createQueryAgentBotContext, queryList } from './queries'
// Types
interface AgentBotContext {
readonly agents: Set<Agent | Promise<Agent>>
}
// State management
const agents = new Set<Agent | Promise<Agent>>()
const logger = useLogg('openai').useGlobalConfig()
// Agent initialization
// export async function initAgent(): Promise<Neuri> {
// logger.log('Initializing agent')
// let n = neuri()
// agents.add(initQueryAgent())
// agents.forEach(agent => n = n.agent(agent))
// return n.build({
// provider: {
// apiKey: openaiConfig.apiKey,
// baseURL: openaiConfig.baseUrl,
// },
// })
// }
// export async function initQueryAgent(): Promise<Agent> {
// logger.log('Initializing query agent')
// let queryAgent = agent('query')
// queryList.forEach((query) => {
// queryAgent = queryAgent.tool(
// query.name,
// query.schema,
// query.perform,
// { description: query.description },
// )
// })
// return queryAgent.build()
// }
export async function initAgent(ctx: BotContext) {
logger.log('Initializing agent')
initQueryAgent(ctx)
}
export function initQueryAgent(ctx: BotContext) {
logger.log('Initializing query agent')
const agentBotContext = createQueryAgentBotContext(ctx.bot)
const tools = []
for (const query of queryList) {
tools.push(
tool({
name: query.name,
description: query.description,
execute: query.perform(agentBotContext),
parameters: query.schema as never,
}),
)
}
return tools
}
+211
View File
@@ -0,0 +1,211 @@
import type { Bot } from 'mineflayer'
import { z } from 'zod'
// Core types
type QueryResult = string | Promise<string>
// BotContext management
let ctx: QueryBotContext
export function initQueryBotContext(BotContext: QueryBotContext): void {
ctx = BotContext
}
interface QueryBotContext {
world: {
getBiomeName: (bot: Bot) => string
getNearbyPlayerNames: (bot: Bot) => string[]
getInventoryCounts: (bot: Bot) => Record<string, number>
getNearbyBlockTypes: (bot: Bot) => string[]
getCraftableItems: (bot: Bot) => string[]
getNearbyEntityTypes: (bot: Bot) => string[]
}
convoManager: {
getInGameAgents: () => string[]
}
}
interface QueryAgentBotContext {
bot: Bot
name: string
actions: {
currentActionLabel: string
}
isIdle: () => boolean
memory_bank: {
getKeys: () => string[]
}
}
export function createQueryAgentBotContext(bot: Bot): QueryAgentBotContext {
return {
bot,
name: bot.username,
actions: {
currentActionLabel: bot.actions.currentActionLabel,
},
isIdle: () => bot.actions.isIdle(),
memory_bank: {
getKeys: () => bot.memory_bank.getKeys(),
},
}
}
interface Query {
readonly name: string
readonly description: string
readonly schema: z.ZodObject<any>
readonly perform: (agent: QueryAgentBotContext) => () => QueryResult
}
// Utils
const pad = (str: string): string => `\n${str}\n`
function formatInventoryItem(item: string, count: number): string {
return count > 0 ? `\n- ${item}: ${count}` : ''
}
function formatWearingItem(slot: string, item: string | undefined): string {
return item ? `\n${slot}: ${item}` : ''
}
// Query implementations
function createStatsQuery(): Query {
return {
name: '!stats',
description: 'Get your bot\'s location, health, hunger, and time of day.',
schema: z.object({}),
perform: (agent: QueryAgentBotContext) => (): string => {
const { bot } = agent
const pos = bot.entity.position
const weather = bot.rainState > 0 ? 'Rain' : bot.thunderState > 0 ? 'Thunderstorm' : 'Clear'
const timeOfDay = bot.time.timeOfDay < 6000
? 'Morning'
: bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night'
const action = agent.isIdle() ? 'Idle' : agent.actions.currentActionLabel
const players = ctx.world.getNearbyPlayerNames(bot)
.filter(p => !ctx.convoManager.getInGameAgents().includes(p))
const bots = ctx.convoManager.getInGameAgents()
.filter(b => b !== agent.name)
return pad(`STATS
- Position: x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}
- Gamemode: ${bot.game.gameMode}
- Health: ${Math.round(bot.health)} / 20
- Hunger: ${Math.round(bot.food)} / 20
- Biome: ${ctx.world.getBiomeName(bot)}
- Weather: ${weather}
- Time: ${timeOfDay}
- Current Action: ${action}
- Nearby Human Players: ${players.length > 0 ? players.join(', ') : 'None.'}
- Nearby Bot Players: ${bots.length > 0 ? bots.join(', ') : 'None.'}
${bot.modes.getMiniDocs()}`)
},
}
}
function createInventoryQuery(): Query {
return {
name: '!inventory',
description: 'Get your bot\'s inventory.',
schema: z.object({}),
perform: (agent: QueryAgentBotContext) => (): string => {
const { bot } = agent
const inventory = ctx.world.getInventoryCounts(bot)
const items = Object.entries(inventory)
.map(([item, count]) => formatInventoryItem(item, count))
.join('')
const wearing = [
formatWearingItem('Head', bot.inventory.slots[5]?.name),
formatWearingItem('Torso', bot.inventory.slots[6]?.name),
formatWearingItem('Legs', bot.inventory.slots[7]?.name),
formatWearingItem('Feet', bot.inventory.slots[8]?.name),
].filter(Boolean).join('')
return pad(`INVENTORY${items || ': Nothing'}
${agent.bot.game.gameMode === 'creative' ? '\n(You have infinite items in creative mode. You do not need to gather resources!!)' : ''}
WEARING: ${wearing || 'Nothing'}`)
},
}
}
function createNearbyBlocksQuery(): Query {
return {
name: '!nearbyBlocks',
description: 'Get the blocks near the bot.',
schema: z.object({}),
perform: (agent: QueryAgentBotContext) => (): string => {
const blocks = ctx.world.getNearbyBlockTypes(agent.bot)
return pad(`NEARBY_BLOCKS${blocks.map(b => `\n- ${b}`).join('') || ': none'}`)
},
}
}
function createCraftableQuery(): Query {
return {
name: '!craftable',
description: 'Get the craftable items with the bot\'s inventory.',
schema: z.object({}),
perform: (agent: QueryAgentBotContext) => (): string => {
const craftable = ctx.world.getCraftableItems(agent.bot)
return pad(`CRAFTABLE_ITEMS${craftable.map(i => `\n- ${i}`).join('') || ': none'}`)
},
}
}
function createEntitiesQuery(): Query {
return {
name: '!entities',
description: 'Get the nearby players and entities.',
schema: z.object({}),
perform: (agent: QueryAgentBotContext) => (): string => {
const { bot } = agent
const players = ctx.world.getNearbyPlayerNames(bot)
.filter(p => !ctx.convoManager.getInGameAgents().includes(p))
const bots = ctx.convoManager.getInGameAgents()
.filter(b => b !== agent.name)
const entities = ctx.world.getNearbyEntityTypes(bot)
.filter(e => e !== 'player' && e !== 'item')
const result = [
...players.map(p => `- Human player: ${p}`),
...bots.map(b => `- Bot player: ${b}`),
...entities.map(e => `- entities: ${e}`),
]
return pad(`NEARBY_ENTITIES${result.length ? `\n${result.join('\n')}` : ': none'}`)
},
}
}
function createModesQuery(): Query {
return {
name: '!modes',
description: 'Get all available modes and their docs and see which are on/off.',
schema: z.object({}),
perform: (agent: QueryAgentBotContext) => (): string => agent.bot.modes.getDocs(),
}
}
function createSavedPlacesQuery(): Query {
return {
name: '!savedPlaces',
description: 'List all saved locations.',
schema: z.object({}),
perform: (agent: QueryAgentBotContext) => (): string =>
`Saved place names: ${agent.memory_bank.getKeys()}`,
}
}
// Export query list
export const queryList: readonly Query[] = [
createStatsQuery(),
createInventoryQuery(),
createNearbyBlocksQuery(),
createCraftableQuery(),
createEntitiesQuery(),
createModesQuery(),
createSavedPlacesQuery(),
] as const
@@ -0,0 +1,46 @@
import type { BotContext, ComponentLifecycle } from '@/composables/bot'
import { commands } from '@/composables/command'
import { formBotChat } from '@/middlewares/chat'
import { parseCommand } from '@/middlewares/command'
import { useLogg } from '@guiiai/logg'
const logger = useLogg('command').useGlobalConfig()
export function createCommandComponent(ctx: BotContext): ComponentLifecycle {
const onChat = formBotChat(ctx, (sender, message) => {
const { isCommand, command, args } = parseCommand(sender, message)
if (!isCommand)
return
// Remove the # prefix from command
const cleanCommand = command.slice(1)
logger.withFields({ sender, command: cleanCommand, args }).log('Command received')
const handler = commands.get(cleanCommand)
if (handler) {
handler({ sender, isCommand, command: cleanCommand, args })
return
}
// Built-in commands
switch (cleanCommand) {
case 'help': {
const commandList = Array.from(commands.keys()).concat(['help'])
ctx.bot.chat(`Available commands: ${commandList.map(cmd => `#${cmd}`).join(', ')}`)
break
}
default:
ctx.bot.chat(`Unknown command: ${cleanCommand}`)
}
})
ctx.bot.on('chat', onChat)
return {
cleanup: () => {
ctx.bot.removeListener('chat', onChat)
},
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
import type { ComponentLifecycle, Context } from '../bot'
import type { BotContext, ComponentLifecycle } from '@/composables/bot'
import { formBotChat } from '@/middlewares/chat'
import { useLogg } from '@guiiai/logg'
import { formBotChat } from 'src/middlewares/chat'
const logger = useLogg('echo').useGlobalConfig()
export function createEchoComponent(ctx: Context): ComponentLifecycle {
export function createEchoComponent(ctx: BotContext): ComponentLifecycle {
const onChat = formBotChat(ctx, (username, message) => {
logger.withFields({ username, message }).log('Chat message received')
ctx.bot.chat(message)
+46 -40
View File
@@ -1,70 +1,76 @@
import type { ComponentLifecycle, Context } from '../bot'
import type { BotContext, ComponentLifecycle } from '@/composables/bot'
import type { CommandContext } from '@/middlewares/command'
import { registerCommand } from '@/composables/command'
import { useLogg } from '@guiiai/logg'
import { goals, Movements, pathfinder } from 'mineflayer-pathfinder'
import { formBotChat } from 'src/middlewares/chat'
export function createFollowComponent(ctx: Context): ComponentLifecycle {
const RANGE_GOAL = 2 // get within this radius of the player
interface FollowContext {
following: string | null
movements: Movements
}
export function createFollowComponent(ctx: BotContext): ComponentLifecycle {
const RANGE_GOAL = 1 // get within this radius of the player
const logger = useLogg('follow').useGlobalConfig()
logger.log('Loading follow plugin')
ctx.bot.loadPlugin(pathfinder)
let defaultMove: Movements
let following: string | null = null
const state: FollowContext = {
following: null,
movements: new Movements(ctx.bot),
}
const followPlayer = () => {
if (!following)
function startFollow(username: string): void {
state.following = username
logger.withFields({ username }).log('Starting to follow player')
followPlayer()
}
function stopFollow(): void {
state.following = null
logger.log('Stopping follow')
ctx.bot.pathfinder.stop()
}
function followPlayer(): void {
if (!state.following)
return
const target = ctx.bot.players[following]?.entity
const target = ctx.bot.players[state.following]?.entity
if (!target) {
ctx.bot.chat('I lost sight of you!')
following = null
state.following = null
return
}
const { x: playerX, y: playerY, z: playerZ } = target.position
ctx.bot.pathfinder.setMovements(defaultMove)
ctx.bot.pathfinder.setMovements(state.movements)
ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL))
}
const onChat = formBotChat(ctx, (username, message) => {
if (username === ctx.bot.username)
registerCommand('follow', (commandCtx: CommandContext) => {
const username = commandCtx.sender
if (!username) {
ctx.bot.chat('Please specify a player name!')
return
}
startFollow(username)
})
if (message === 'follow') {
following = username
logger.withFields({ username }).log('Starting to follow player')
registerCommand('stop', () => {
stopFollow()
})
// Continuously update path to follow player
const followInterval = setInterval(() => {
if (state.following)
followPlayer()
}
else if (message === 'stop') {
following = null
logger.log('Stopping follow')
ctx.bot.pathfinder.stop()
}
})
ctx.bot.once('spawn', () => {
defaultMove = new Movements(ctx.bot)
ctx.bot.on('chat', onChat)
// Continuously update path to follow player
const followInterval = setInterval(() => {
if (following)
followPlayer()
}, 1000)
ctx.bot.once('end', () => {
clearInterval(followInterval)
})
})
}, 1000)
return {
cleanup: () => {
ctx.bot.removeListener('chat', onChat)
clearInterval(followInterval)
},
}
}
@@ -1,9 +1,10 @@
import type { ComponentLifecycle, Context } from '../bot'
import type { BotContext, ComponentLifecycle } from '@/composables/bot'
import type { CommandContext } from '@/middlewares/command'
import { registerCommand } from '@/composables/command'
import { useLogg } from '@guiiai/logg'
import { goals, Movements, pathfinder } from 'mineflayer-pathfinder'
import { formBotChat } from 'src/middlewares/chat'
export function createPathFinderComponent(ctx: Context): ComponentLifecycle {
export function createPathFinderComponent(ctx: BotContext): ComponentLifecycle {
const RANGE_GOAL = 1 // get within this radius of the player
const logger = useLogg('pathfinder').useGlobalConfig()
@@ -13,14 +14,17 @@ export function createPathFinderComponent(ctx: Context): ComponentLifecycle {
let defaultMove: Movements
const onChat = formBotChat(ctx, (username, message) => {
if (message !== 'come')
const handleCome = (commandCtx: CommandContext) => {
const username = commandCtx.sender
if (!username) {
ctx.bot.chat('Please specify a player name!')
return
}
logger.withFields({ username, message }).log('Chat message received')
logger.withFields({ username }).log('Come command received')
const target = ctx.bot.players[username]?.entity
if (!target) {
ctx.bot.chat('I don\'t see you !')
ctx.bot.chat('I don\'t see that player!')
return
}
@@ -28,16 +32,14 @@ export function createPathFinderComponent(ctx: Context): ComponentLifecycle {
ctx.bot.pathfinder.setMovements(defaultMove)
ctx.bot.pathfinder.setGoal(new goals.GoalNear(playerX, playerY, playerZ, RANGE_GOAL))
})
}
ctx.bot.once('spawn', () => {
defaultMove = new Movements(ctx.bot)
ctx.bot.on('chat', onChat)
})
defaultMove = new Movements(ctx.bot)
registerCommand('come', handleCome)
return {
cleanup: () => {
ctx.bot.removeListener('chat', onChat)
// Commands are cleaned up automatically
},
}
}
@@ -0,0 +1,34 @@
import type { BotContext, ComponentLifecycle } from '@/composables/bot'
import { registerCommand } from '@/composables/command'
import { useLogg } from '@guiiai/logg'
export function createStatusComponent(ctx: BotContext): ComponentLifecycle {
const logger = useLogg('status').useGlobalConfig()
logger.log('Loading status component')
const handleStatus = () => {
const pos = ctx.bot.entity.position
const weather = ctx.bot.isRaining ? 'Rain' : ctx.bot.thunderState ? 'Thunderstorm' : 'Clear'
const timeOfDay = ctx.bot.time.timeOfDay < 6000
? 'Morning'
: ctx.bot.time.timeOfDay < 12000 ? 'Afternoon' : 'Night'
ctx.bot.chat(`Status:
Position: x: ${pos.x.toFixed(2)}, y: ${pos.y.toFixed(2)}, z: ${pos.z.toFixed(2)}
Health: ${Math.round(ctx.bot.health)} / 20
Hunger: ${Math.round(ctx.bot.food)} / 20
Weather: ${weather}
Time: ${timeOfDay}`)
}
registerCommand('status', () => {
logger.log('Status command received')
handleStatus()
})
return {
cleanup: () => {
// Commands are cleaned up automatically
},
}
}
@@ -3,15 +3,23 @@ import mineflayer, { type Bot, type BotOptions } from 'mineflayer'
const logger = useLogg('bot').useGlobalConfig()
let ctx: Context | undefined
let ctx: BotContext | undefined
export interface Context {
export interface BotContext {
bot: Bot
components: Map<string, ComponentLifecycle>
botName: string
prompt: {
selfPrompt: string
}
memory: {
getSummary: () => string
}
}
export interface Component {
(ctx: Context): ComponentLifecycle
(ctx: BotContext): ComponentLifecycle
}
export interface ComponentLifecycle {
@@ -28,6 +36,13 @@ export function createBot(options: BotOptions): Bot {
password: options.password,
}),
components: new Map(),
botName: options.username,
prompt: {
selfPrompt: '',
},
memory: {
getSummary: () => '',
},
}
ctx.bot.on('error', (err: Error) => {
@@ -49,19 +64,19 @@ export function useBot() {
const cleanup = () => {
logger.log('Cleaning up bot and components')
ctx!.components.forEach((context: ComponentLifecycle) => context.cleanup?.())
ctx!.components.forEach((BotContext: ComponentLifecycle) => BotContext.cleanup?.())
ctx!.components.clear()
ctx!.bot.end()
}
const registerComponent = (componentName: string, component: Component) => {
logger.withFields({ componentName }).log('Registering new component')
const context = component(ctx!)
const BotContext = component(ctx!)
if (context != null)
ctx!.components.set(componentName, context)
if (BotContext != null)
ctx!.components.set(componentName, BotContext)
return context
return BotContext
}
const listComponents = () => {
@@ -0,0 +1,10 @@
import type { CommandContext } from '@/middlewares/command'
export const commands = new Map<string, (ctx: CommandContext) => void>()
export function registerCommand(command: string, handler: (ctx: CommandContext) => void) {
if (commands.has(command))
throw new Error(`Command ${command} already registered`)
commands.set(command, handler)
}
+16 -9
View File
@@ -1,11 +1,13 @@
import process from 'node:process'
import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg'
import { createBot, useBot } from './bot'
import { createEchoComponent } from './components/echo'
import { createCommandComponent } from './components/command'
import { createFollowComponent } from './components/follow'
import { createPathFinderComponent } from './components/patchfinder'
import { botConfig, initEnv } from './config'
import { createPathFinderComponent } from './components/pathfinder'
import { createStatusComponent } from './components/status'
import { createBot, useBot } from './composables/bot'
import { botConfig, initEnv } from './composables/config'
const logger = useLogg('main').useGlobalConfig()
@@ -16,12 +18,17 @@ async function main() {
initEnv()
createBot(botConfig)
const { cleanup, registerComponent } = useBot()
const { cleanup, registerComponent, ctx } = useBot()
registerComponent('echo', createEchoComponent)
registerComponent('pathfinder', createPathFinderComponent)
// registerComponent('chest', createChestComponent)
registerComponent('follow', createFollowComponent)
ctx.bot.once('spawn', () => {
registerComponent('status', createStatusComponent)
// registerComponent('echo', createEchoComponent)
registerComponent('pathfinder', createPathFinderComponent)
registerComponent('follow', createFollowComponent)
registerComponent('command', createCommandComponent)
})
// initAgent(ctx)
process.on('SIGINT', () => {
cleanup()
+4 -4
View File
@@ -1,8 +1,8 @@
import type { BotContext } from '@/composables/bot'
import type { Entity } from 'prismarine-entity'
import type { Context } from 'src/bot'
// TODO: need to be refactored
interface ChatContext {
interface ChatBotContext {
fromUsername?: string
fromEntity?: Entity
fromMessage?: string
@@ -11,7 +11,7 @@ interface ChatContext {
isCommand: () => boolean
}
export function newChatContext(ctx: Context, username: string, message: string): ChatContext {
export function newChatBotContext(ctx: BotContext, username: string, message: string): ChatBotContext {
return {
fromUsername: username,
fromEntity: ctx.bot.entity,
@@ -21,7 +21,7 @@ export function newChatContext(ctx: Context, username: string, message: string):
}
}
export function formBotChat(ctx: Context, cb: (username: string, message: string) => void) {
export function formBotChat(ctx: BotContext, cb: (username: string, message: string) => void) {
return (username: string, message: string) => {
if (ctx.bot.username === username)
return
@@ -0,0 +1,13 @@
export interface CommandContext {
sender: string
isCommand: boolean
command: string
args: string[]
}
export function parseCommand(sender: string, message: string): CommandContext {
const isCommand = message.startsWith('#')
const command = message.split(' ')[0]
const args = message.split(' ').slice(1)
return { sender, isCommand, command, args }
}
+74
View File
@@ -0,0 +1,74 @@
import type { BotContext } from '@/composables/bot'
export function genSystemPrompt(ctx: BotContext): string {
return `
You are a playful Minecraft bot named ${ctx.botName} that can converse with players, see, move,
mine, build, and interact with the world by using commands.
${ctx.prompt.selfPrompt}
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.
Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped.',
instead say this: 'Sure, I'll stop. !stop'.
Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer("playername", 3)'.
Respond only as ${ctx.botName}, never output '(FROM OTHER BOT)'or pretend to be someone else.
If you have nothing to say or do, respond with an just a tab '\t'.
This is extremely important to me, take a deep breath and have fun :)
Summarized memory: '${ctx.memory.getSummary()}'
$STATS
$INVENTORY
$COMMAND_DOCS
$EXAMPLES
Conversation Begin:
`
}
export function genQueryAgentPrompt(tools: string[], status: Map<string, string>): string {
const BotContextFields: readonly string[] = [
'Biome',
'Time',
'Nearby blocks',
'Other blocks that are recently seen',
'Nearby entities (nearest to farthest)',
'Health',
'Hunger',
'Position',
'Equipment',
'Inventory (xx/36)',
'Chests',
'Completed tasks so far',
'Failed tasks that are too hard',
] as const
const formatBotContextFields = (fields: readonly string[]): string =>
fields.map((field) => {
const value = status.get(field) || '...'
return `${field}: ${value}`
}).join('\n')
const formatTools = (toolList: string[]): string =>
toolList.join('\n')
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:
${formatBotContextFields(BotContextFields)}
And I will give you some tools to use:
${formatTools(tools)}
Then you can choose some of the tools to use. Use the valid JS call function to call the tool.
`
return prompt
}
+1 -1
View File
@@ -2,6 +2,7 @@
* @source https://github.com/kolbytn/mindcraft
*/
import type { Bot } from 'mineflayer'
import { botConfig } from '@/composables/config'
import minecraftData from 'minecraft-data'
import { createBot } from 'mineflayer'
import armorManager from 'mineflayer-armor-manager'
@@ -10,7 +11,6 @@ import { plugin as collectblock } from 'mineflayer-collectblock'
import { pathfinder } from 'mineflayer-pathfinder'
import { plugin as pvp } from 'mineflayer-pvp'
import prismarine_items from 'prismarine-item'
import { botConfig } from '../config'
const mc_version = botConfig.version!
const mcdata = minecraftData(mc_version)