feat(minecraft): run conscious brain in js repl with runtime globals
This commit is contained in:
@@ -7,7 +7,6 @@ import { collectBlock } from '../../skills/actions/collect-block'
|
||||
import { discard, equip, putInChest, takeFromChest } from '../../skills/actions/inventory'
|
||||
import { activateNearestBlock, breakBlockAt, placeBlock } from '../../skills/actions/world-interactions'
|
||||
import { ActionError } from '../../utils/errors'
|
||||
import { javascriptRepl } from '../../utils/javascript-repl'
|
||||
import { useLogger } from '../../utils/logger'
|
||||
import { describeRecipePlan, planRecipe } from '../../utils/recipe-planner'
|
||||
|
||||
@@ -38,15 +37,6 @@ export const actionsList: Action[] = [
|
||||
return `Sent message: "${message}"`
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'eval',
|
||||
description: 'Evaluate JavaScript code in a persistent REPL context. Use this for quick calculations or temporary memory.',
|
||||
execution: 'sync',
|
||||
schema: z.object({
|
||||
code: z.string().min(1).describe('JavaScript source code to evaluate.'),
|
||||
}),
|
||||
perform: () => (code: string): string => javascriptRepl.evaluate(code),
|
||||
},
|
||||
// {\n // name: 'setReflexMode',
|
||||
// description: 'Set (or clear) your reflex mode override. Use work/wander to disable idle-only reflex behaviors. Set override to null to return to automatic mode selection.',
|
||||
// execution: 'sequential',
|
||||
|
||||
@@ -45,24 +45,28 @@ export class TaskExecutor extends EventEmitter {
|
||||
}
|
||||
|
||||
public async executeAction(action: ActionInstruction, cancellationToken?: CancellationToken): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
throw new Error('TaskExecutor not initialized')
|
||||
}
|
||||
|
||||
if (cancellationToken?.isCancelled) {
|
||||
this.logger.log('Action execution cancelled before start')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.runSingleAction(action)
|
||||
await this.executeActionWithResult(action, cancellationToken)
|
||||
}
|
||||
catch (error) {
|
||||
// Errors handled in runSingleAction event emission
|
||||
}
|
||||
}
|
||||
|
||||
private async runSingleAction(action: ActionInstruction): Promise<void> {
|
||||
public async executeActionWithResult(action: ActionInstruction, cancellationToken?: CancellationToken): Promise<string | void> {
|
||||
if (!this.initialized) {
|
||||
throw new Error('TaskExecutor not initialized')
|
||||
}
|
||||
|
||||
if (cancellationToken?.isCancelled) {
|
||||
this.logger.log('Action execution cancelled before start')
|
||||
return 'Action cancelled'
|
||||
}
|
||||
|
||||
return this.runSingleAction(action)
|
||||
}
|
||||
|
||||
private async runSingleAction(action: ActionInstruction): Promise<string | void> {
|
||||
this.emit('action:started', { action })
|
||||
|
||||
try {
|
||||
@@ -79,7 +83,7 @@ export class TaskExecutor extends EventEmitter {
|
||||
}
|
||||
|
||||
this.mineflayer.bot.chat(message)
|
||||
result = 'Message sent'
|
||||
result = `Sent message: "${message}"`
|
||||
}
|
||||
else if (action.tool === 'skip') {
|
||||
result = 'Skipped turn'
|
||||
@@ -96,6 +100,7 @@ export class TaskExecutor extends EventEmitter {
|
||||
}
|
||||
|
||||
this.emit('action:completed', { action, result })
|
||||
return result
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.withError(error).error('Action execution failed')
|
||||
|
||||
@@ -14,7 +14,6 @@ import { buildConsciousContextView } from './context-view'
|
||||
import { JavaScriptPlanner } from './js-planner'
|
||||
import { LLMAgent } from './llm-agent'
|
||||
import {
|
||||
extractJsonCandidate,
|
||||
isLikelyAuthOrBadArgError,
|
||||
isRateLimitError,
|
||||
sleep,
|
||||
@@ -23,10 +22,6 @@ import {
|
||||
import { generateBrainSystemPrompt } from './prompts/brain-prompt'
|
||||
import { createCancellationToken, type CancellationToken } from './task-state'
|
||||
|
||||
interface BrainResponse {
|
||||
action: ActionInstruction & { id?: string }
|
||||
}
|
||||
|
||||
interface BrainDeps {
|
||||
eventBus: EventBus
|
||||
llmAgent: LLMAgent
|
||||
@@ -73,9 +68,6 @@ export class Brain {
|
||||
this.deps.taskExecutor.on('action:completed', async ({ action, result }) => {
|
||||
this.deps.logger.log('INFO', `Brain: Action completed: ${action.tool}`)
|
||||
|
||||
// Suppress feedback for chat actions on success
|
||||
if (action.tool === 'chat') return
|
||||
|
||||
this.enqueueEvent(bot, {
|
||||
type: 'feedback',
|
||||
payload: { status: 'success', action, result },
|
||||
@@ -231,8 +223,6 @@ export class Brain {
|
||||
}
|
||||
|
||||
try {
|
||||
const actions = this.parseResponse(result)
|
||||
|
||||
// Only append to conversation history after successful parsing (avoid dirty data on retry)
|
||||
this.conversationHistory.push({ role: 'user', content: userMessage })
|
||||
// Store reasoning in the assistant message's reasoning field (if available)
|
||||
@@ -243,17 +233,46 @@ export class Brain {
|
||||
...(capturedReasoning && { reasoning: capturedReasoning }),
|
||||
} as Message)
|
||||
|
||||
if (actions.length === 1 && actions[0]?.tool === 'skip') {
|
||||
const actionDefs = new Map(this.deps.taskExecutor.getAvailableActions().map(action => [action.name, action]))
|
||||
let turnCancellationToken: CancellationToken | undefined
|
||||
|
||||
const runResult = await this.planner.evaluate(
|
||||
result,
|
||||
this.deps.taskExecutor.getAvailableActions(),
|
||||
{ event, snapshot: snapshot as unknown as Record<string, unknown> },
|
||||
async (action: ActionInstruction) => {
|
||||
const actionDef = actionDefs.get(action.tool)
|
||||
const isPhysicalAction = action.tool !== 'skip' && !actionDef?.readonly
|
||||
|
||||
if (isPhysicalAction) {
|
||||
if (!turnCancellationToken) {
|
||||
this.currentCancellationToken?.cancel()
|
||||
this.currentCancellationToken = createCancellationToken()
|
||||
turnCancellationToken = this.currentCancellationToken
|
||||
}
|
||||
return this.deps.taskExecutor.executeActionWithResult(action, turnCancellationToken)
|
||||
}
|
||||
|
||||
return this.deps.taskExecutor.executeActionWithResult(action)
|
||||
},
|
||||
)
|
||||
|
||||
if (runResult.actions.length === 0 || runResult.actions.every(item => item.action.tool === 'skip')) {
|
||||
this.deps.logger.log('INFO', 'Brain: Skipping turn (observing)')
|
||||
return
|
||||
}
|
||||
|
||||
this.deps.logger.log('INFO', `Brain: Planned ${actions.length} action(s)`, {
|
||||
actions: actions.map(action => ({ tool: action.tool, params: action.params })),
|
||||
this.deps.logger.log('INFO', `Brain: Executed ${runResult.actions.length} action(s)`, {
|
||||
actions: runResult.actions.map(item => ({
|
||||
tool: item.action.tool,
|
||||
ok: item.ok,
|
||||
result: item.result,
|
||||
error: item.error,
|
||||
})),
|
||||
logs: runResult.logs,
|
||||
returnValue: runResult.returnValue,
|
||||
})
|
||||
|
||||
this.executePlannedActions(actions)
|
||||
|
||||
} catch (err) {
|
||||
this.deps.logger.withError(err).error('Brain: Failed to execute decision')
|
||||
void this.enqueueEvent(bot, {
|
||||
@@ -295,59 +314,8 @@ export class Brain {
|
||||
// Note: We don't update this.lastContextView here; caller does it after building message
|
||||
}
|
||||
|
||||
parts.push('[RUNTIME] Globals are refreshed every turn: snapshot, self, environment, social, threat, attention, event, now, mem, lastRun, lastAction.')
|
||||
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
private parseResponse(content: string): ActionInstruction[] {
|
||||
const availableActions = this.deps.taskExecutor.getAvailableActions()
|
||||
const trimmed = content.trim()
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(extractJsonCandidate(content)) as BrainResponse
|
||||
if (parsed?.action?.tool === 'skip') {
|
||||
return [{ tool: 'skip', params: {} }]
|
||||
}
|
||||
|
||||
if (parsed?.action?.tool) {
|
||||
const actionDef = availableActions.find(action => action.name === parsed.action.tool)
|
||||
if (!actionDef) {
|
||||
throw new Error(`Unknown tool in legacy JSON response: ${parsed.action.tool}`)
|
||||
}
|
||||
|
||||
return [{
|
||||
tool: actionDef.name,
|
||||
params: actionDef.schema.parse(parsed.action.params ?? {}),
|
||||
}]
|
||||
}
|
||||
} catch {
|
||||
// Fallback to JS planner parsing.
|
||||
}
|
||||
}
|
||||
|
||||
return this.planner.evaluate(content, availableActions)
|
||||
}
|
||||
|
||||
private executePlannedActions(actions: ActionInstruction[]): void {
|
||||
const availableActions = this.deps.taskExecutor.getAvailableActions()
|
||||
const actionDefs = new Map(availableActions.map(action => [action.name, action]))
|
||||
const hasNonReadonlyAction = actions.some(action => !actionDefs.get(action.tool)?.readonly && action.tool !== 'skip')
|
||||
|
||||
let token: CancellationToken | undefined
|
||||
if (hasNonReadonlyAction) {
|
||||
if (this.currentCancellationToken) {
|
||||
this.currentCancellationToken.cancel()
|
||||
}
|
||||
this.currentCancellationToken = createCancellationToken()
|
||||
token = this.currentCancellationToken
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
for (const action of actions) {
|
||||
if (token?.isCancelled)
|
||||
return
|
||||
|
||||
await this.deps.taskExecutor.executeAction(action, token)
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Action } from '../../libs/mineflayer/action'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { JavaScriptPlanner } from './js-planner'
|
||||
@@ -24,51 +24,76 @@ const actions: Action[] = [
|
||||
]
|
||||
|
||||
describe('JavaScriptPlanner', () => {
|
||||
it('maps positional and object tool args into validated action instructions', () => {
|
||||
const planner = new JavaScriptPlanner()
|
||||
const planned = planner.evaluate(`
|
||||
chat("hello")
|
||||
goToPlayer({ player_name: "Alex", closeness: 2 })
|
||||
`, actions)
|
||||
const globals = {
|
||||
event: {
|
||||
type: 'perception',
|
||||
payload: { type: 'chat_message' },
|
||||
source: { type: 'minecraft', id: 'test' },
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
snapshot: {
|
||||
self: { health: 20, food: 20, location: { x: 0, y: 64, z: 0 } },
|
||||
environment: { nearbyPlayers: [] },
|
||||
social: {},
|
||||
threat: {},
|
||||
attention: {},
|
||||
},
|
||||
} as any
|
||||
|
||||
expect(planned).toEqual([
|
||||
it('maps positional/object args and executes tools in order', async () => {
|
||||
const planner = new JavaScriptPlanner()
|
||||
const executeAction = vi.fn(async action => `ok:${action.tool}`)
|
||||
const planned = await planner.evaluate(`
|
||||
await chat("hello")
|
||||
await goToPlayer({ player_name: "Alex", closeness: 2 })
|
||||
`, actions, globals, executeAction)
|
||||
|
||||
expect(executeAction).toHaveBeenCalledTimes(2)
|
||||
expect(executeAction).toHaveBeenNthCalledWith(1, { tool: 'chat', params: { message: 'hello' } })
|
||||
expect(executeAction).toHaveBeenNthCalledWith(2, { tool: 'goToPlayer', params: { player_name: 'Alex', closeness: 2 } })
|
||||
expect(planned.actions.map(a => a.action)).toEqual([
|
||||
{ tool: 'chat', params: { message: 'hello' } },
|
||||
{ tool: 'goToPlayer', params: { player_name: 'Alex', closeness: 2 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('supports dynamic dispatch with use(toolName, params)', () => {
|
||||
it('supports dynamic dispatch with use(toolName, params)', async () => {
|
||||
const planner = new JavaScriptPlanner()
|
||||
const planned = planner.evaluate(`use("chat", { message: "via-use" })`, actions)
|
||||
const executeAction = vi.fn(async action => `ok:${action.tool}`)
|
||||
const planned = await planner.evaluate(`await use("chat", { message: "via-use" })`, actions, globals, executeAction)
|
||||
|
||||
expect(planned).toEqual([{ tool: 'chat', params: { message: 'via-use' } }])
|
||||
expect(planned.actions.map(a => a.action)).toEqual([{ tool: 'chat', params: { message: 'via-use' } }])
|
||||
})
|
||||
|
||||
it('persists script variables across turns', () => {
|
||||
it('persists script variables across turns with mem', async () => {
|
||||
const planner = new JavaScriptPlanner()
|
||||
const executeAction = vi.fn(async action => `ok:${action.tool}`)
|
||||
|
||||
planner.evaluate('const count = 2', actions)
|
||||
const planned = planner.evaluate('chat("count=" + count)', actions)
|
||||
await planner.evaluate('mem.count = 2', actions, globals, executeAction)
|
||||
const planned = await planner.evaluate('await chat("count=" + mem.count)', actions, globals, executeAction)
|
||||
|
||||
expect(planned).toEqual([{ tool: 'chat', params: { message: 'count=2' } }])
|
||||
expect(planned.actions.map(a => a.action)).toEqual([{ tool: 'chat', params: { message: 'count=2' } }])
|
||||
})
|
||||
|
||||
it('returns skip when no tool is called', () => {
|
||||
it('provides snapshot globals in script scope', async () => {
|
||||
const planner = new JavaScriptPlanner()
|
||||
const planned = planner.evaluate('const x = 1 + 1', actions)
|
||||
const executeAction = vi.fn(async action => `ok:${action.tool}`)
|
||||
const planned = await planner.evaluate('await chat("hp=" + self.health)', actions, globals, executeAction)
|
||||
|
||||
expect(planned).toEqual([{ tool: 'skip', params: {} }])
|
||||
expect(planned.actions.map(a => a.action)).toEqual([{ tool: 'chat', params: { message: 'hp=20' } }])
|
||||
})
|
||||
|
||||
it('rejects mixed skip + tool calls', () => {
|
||||
it('rejects mixed skip + tool calls', async () => {
|
||||
const planner = new JavaScriptPlanner()
|
||||
const executeAction = vi.fn(async action => `ok:${action.tool}`)
|
||||
|
||||
expect(() => planner.evaluate('skip(); chat("oops")', actions)).toThrow(/skip\(\) cannot be mixed/i)
|
||||
await expect(planner.evaluate('await skip(); await chat("oops")', actions, globals, executeAction)).rejects.toThrow(/skip\(\) cannot be mixed/i)
|
||||
})
|
||||
|
||||
it('enforces timeout on long-running scripts', () => {
|
||||
it('enforces timeout on long-running scripts', async () => {
|
||||
const planner = new JavaScriptPlanner({ timeoutMs: 20 })
|
||||
const executeAction = vi.fn(async action => `ok:${action.tool}`)
|
||||
|
||||
expect(() => planner.evaluate('while (true) {}', actions)).toThrow(/Script execution timed out/i)
|
||||
await expect(planner.evaluate('while (true) {}', actions, globals, executeAction)).rejects.toThrow(/Script execution timed out/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Action } from '../../libs/mineflayer/action'
|
||||
import type { ActionInstruction } from '../action/types'
|
||||
import type { BotEvent } from '../types'
|
||||
|
||||
import { inspect } from 'node:util'
|
||||
import vm from 'node:vm'
|
||||
@@ -9,21 +10,53 @@ interface JavaScriptPlannerOptions {
|
||||
maxActionsPerTurn?: number
|
||||
}
|
||||
|
||||
interface ActionIntent {
|
||||
index: number
|
||||
params: Record<string, unknown>
|
||||
tool: string
|
||||
interface ActionRuntimeResult {
|
||||
action: ActionInstruction
|
||||
ok: boolean
|
||||
result?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface ActivePlannerRun {
|
||||
actions: ActionInstruction[]
|
||||
actionCount: number
|
||||
actionsByName: Map<string, Action>
|
||||
executeAction: (action: ActionInstruction) => Promise<string | void>
|
||||
executed: ActionRuntimeResult[]
|
||||
logs: string[]
|
||||
sawSkip: boolean
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (!value || typeof value !== 'object')
|
||||
return value
|
||||
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
const child = (value as Record<string, unknown>)[key]
|
||||
deepFreeze(child)
|
||||
}
|
||||
|
||||
return Object.freeze(value)
|
||||
}
|
||||
|
||||
function toStructuredClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
|
||||
export interface RuntimeGlobals {
|
||||
event: BotEvent
|
||||
snapshot: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface JavaScriptRunResult {
|
||||
actions: ActionRuntimeResult[]
|
||||
logs: string[]
|
||||
returnValue?: string
|
||||
}
|
||||
|
||||
export function extractJavaScriptCandidate(input: string): string {
|
||||
const trimmed = input.trim()
|
||||
const fenced = trimmed.match(/^```(?:js|javascript|ts|typescript)?\s*([\s\S]*?)\s*```$/i)
|
||||
@@ -48,59 +81,94 @@ export class JavaScriptPlanner {
|
||||
this.installBuiltins()
|
||||
}
|
||||
|
||||
public evaluate(content: string, availableActions: Action[]): ActionInstruction[] {
|
||||
public async evaluate(
|
||||
content: string,
|
||||
availableActions: Action[],
|
||||
globals: RuntimeGlobals,
|
||||
executeAction: (action: ActionInstruction) => Promise<string | void>,
|
||||
): Promise<JavaScriptRunResult> {
|
||||
const script = extractJavaScriptCandidate(content)
|
||||
const run: ActivePlannerRun = {
|
||||
actions: [],
|
||||
actionCount: 0,
|
||||
actionsByName: new Map(availableActions.map(action => [action.name, action])),
|
||||
executeAction,
|
||||
executed: [],
|
||||
logs: [],
|
||||
sawSkip: false,
|
||||
}
|
||||
|
||||
this.activeRun = run
|
||||
this.installActionTools(availableActions)
|
||||
this.bindRuntimeGlobals(globals, run)
|
||||
|
||||
try {
|
||||
const result = new vm.Script(script).runInContext(this.context, { timeout: this.timeoutMs })
|
||||
if (typeof result !== 'undefined' && run.actions.length === 0) {
|
||||
// Keep this visible in traces for debugging, without affecting behavior.
|
||||
this.sandbox.__lastEvalResult = inspect(result, { depth: 2, breakLength: 100 })
|
||||
const wrapped = `(async () => {\n${script}\n})()`
|
||||
const result = await new vm.Script(wrapped).runInContext(this.context, { timeout: this.timeoutMs })
|
||||
|
||||
const returnValue = typeof result === 'undefined'
|
||||
? undefined
|
||||
: inspect(result, { depth: 2, breakLength: 100 })
|
||||
|
||||
return {
|
||||
actions: run.executed,
|
||||
logs: run.logs,
|
||||
returnValue,
|
||||
}
|
||||
}
|
||||
finally {
|
||||
this.activeRun = null
|
||||
}
|
||||
|
||||
if (run.actions.length === 0)
|
||||
return [{ tool: 'skip', params: {} }]
|
||||
|
||||
const containsSkip = run.actions.some(action => action.tool === 'skip')
|
||||
if (containsSkip && run.actions.length > 1) {
|
||||
throw new Error('skip() cannot be mixed with other tool calls in the same script')
|
||||
}
|
||||
|
||||
return run.actions
|
||||
}
|
||||
|
||||
private installBuiltins(): void {
|
||||
this.defineGlobalTool('skip', () => this.enqueueAction('skip', {}))
|
||||
this.defineGlobalTool('skip', async () => this.runAction('skip', {}))
|
||||
this.defineGlobalTool('use', (toolName: unknown, params?: unknown) => {
|
||||
if (typeof toolName !== 'string' || toolName.length === 0) {
|
||||
throw new Error('use(toolName, params) requires a non-empty string toolName')
|
||||
}
|
||||
|
||||
const mappedParams = isRecord(params) ? params : {}
|
||||
return this.enqueueAction(toolName, mappedParams)
|
||||
return this.runAction(toolName, mappedParams)
|
||||
})
|
||||
this.defineGlobalTool('log', (...args: unknown[]) => {
|
||||
if (!this.activeRun)
|
||||
throw new Error('log() is only allowed during planner evaluation')
|
||||
|
||||
const rendered = args.map(arg => inspect(arg, { depth: 4, breakLength: 120 })).join(' ')
|
||||
this.activeRun.logs.push(rendered)
|
||||
return rendered
|
||||
})
|
||||
this.defineGlobalValue('mem', {})
|
||||
}
|
||||
|
||||
private installActionTools(availableActions: Action[]): void {
|
||||
for (const action of availableActions) {
|
||||
this.defineGlobalTool(action.name, (...args: unknown[]) => {
|
||||
this.defineGlobalTool(action.name, async (...args: unknown[]) => {
|
||||
const params = this.mapArgsToParams(action, args)
|
||||
return this.enqueueAction(action.name, params)
|
||||
return this.runAction(action.name, params)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private bindRuntimeGlobals(globals: RuntimeGlobals, run: ActivePlannerRun): void {
|
||||
const snapshot = deepFreeze(toStructuredClone(globals.snapshot))
|
||||
const event = deepFreeze(toStructuredClone(globals.event))
|
||||
|
||||
this.sandbox.prevRun = this.sandbox.lastRun ?? null
|
||||
this.sandbox.snapshot = snapshot
|
||||
this.sandbox.event = event
|
||||
this.sandbox.now = Date.now()
|
||||
this.sandbox.self = snapshot.self
|
||||
this.sandbox.environment = snapshot.environment
|
||||
this.sandbox.social = snapshot.social
|
||||
this.sandbox.threat = snapshot.threat
|
||||
this.sandbox.attention = snapshot.attention
|
||||
this.sandbox.lastRun = {
|
||||
actions: run.executed,
|
||||
logs: run.logs,
|
||||
}
|
||||
}
|
||||
|
||||
private mapArgsToParams(action: Action, args: unknown[]): Record<string, unknown> {
|
||||
const shape = action.schema.shape as Record<string, unknown>
|
||||
const keys = Object.keys(shape)
|
||||
@@ -127,44 +195,87 @@ export class JavaScriptPlanner {
|
||||
return params
|
||||
}
|
||||
|
||||
private enqueueAction(tool: string, params: Record<string, unknown>): ActionIntent {
|
||||
private async runAction(tool: string, params: Record<string, unknown>): Promise<ActionRuntimeResult> {
|
||||
if (!this.activeRun) {
|
||||
throw new Error('Tool calls are only allowed during planner evaluation')
|
||||
}
|
||||
|
||||
if (this.activeRun.actions.length >= this.maxActionsPerTurn) {
|
||||
if (this.activeRun.sawSkip && tool !== 'skip') {
|
||||
throw new Error('skip() cannot be mixed with other tool calls in the same script')
|
||||
}
|
||||
|
||||
if (this.activeRun.actionCount >= this.maxActionsPerTurn) {
|
||||
throw new Error(`Action limit exceeded: max ${this.maxActionsPerTurn} actions per turn`)
|
||||
}
|
||||
|
||||
if (tool !== 'skip') {
|
||||
const action = this.activeRun.actionsByName.get(tool)
|
||||
if (!action)
|
||||
throw new Error(`Unknown tool: ${tool}`)
|
||||
|
||||
const parsed = action.schema.parse(params)
|
||||
this.activeRun.actions.push({
|
||||
tool,
|
||||
params: parsed,
|
||||
})
|
||||
}
|
||||
else {
|
||||
this.activeRun.actions.push({ tool: 'skip', params: {} })
|
||||
if (tool === 'skip') {
|
||||
this.activeRun.sawSkip = true
|
||||
}
|
||||
|
||||
const intent: ActionIntent = {
|
||||
index: this.activeRun.actions.length - 1,
|
||||
const action = tool === 'skip'
|
||||
? { tool: 'skip', params: {} as Record<string, unknown> }
|
||||
: this.validateAction(tool, params)
|
||||
|
||||
this.activeRun.actionCount++
|
||||
|
||||
if (tool === 'skip') {
|
||||
const runtimeResult: ActionRuntimeResult = {
|
||||
action,
|
||||
ok: true,
|
||||
result: 'Skipped turn',
|
||||
}
|
||||
this.activeRun.executed.push(runtimeResult)
|
||||
this.sandbox.lastAction = runtimeResult
|
||||
return runtimeResult
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.activeRun.executeAction(action)
|
||||
const runtimeResult: ActionRuntimeResult = {
|
||||
action,
|
||||
ok: true,
|
||||
result: typeof result === 'string' ? result : undefined,
|
||||
}
|
||||
this.activeRun.executed.push(runtimeResult)
|
||||
this.sandbox.lastAction = runtimeResult
|
||||
return runtimeResult
|
||||
}
|
||||
catch (error) {
|
||||
const runtimeResult: ActionRuntimeResult = {
|
||||
action,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
this.activeRun.executed.push(runtimeResult)
|
||||
this.sandbox.lastAction = runtimeResult
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private validateAction(tool: string, params: Record<string, unknown>): ActionInstruction {
|
||||
if (!this.activeRun)
|
||||
throw new Error('Tool calls are only allowed during planner evaluation')
|
||||
|
||||
const action = this.activeRun.actionsByName.get(tool)
|
||||
if (!action)
|
||||
throw new Error(`Unknown tool: ${tool}`)
|
||||
|
||||
return {
|
||||
tool,
|
||||
params,
|
||||
params: action.schema.parse(params),
|
||||
}
|
||||
return Object.freeze(intent)
|
||||
}
|
||||
|
||||
private defineGlobalTool(name: string, fn: (...args: unknown[]) => unknown): void {
|
||||
this.defineGlobalValue(name, fn)
|
||||
}
|
||||
|
||||
private defineGlobalValue(name: string, value: unknown): void {
|
||||
if (Object.prototype.hasOwnProperty.call(this.sandbox, name))
|
||||
return
|
||||
|
||||
Object.defineProperty(this.sandbox, name, {
|
||||
value: fn,
|
||||
value,
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
|
||||
@@ -66,7 +66,7 @@ You are an autonomous agent playing Minecraft.
|
||||
|
||||
# Self-Knowledge & Capabilities
|
||||
1. **Stateful Existence**: You maintain a memory of the conversation, but it's crucial to be aware that old history messages are less relevant than recent.
|
||||
2. **Action Script Per Turn**: You can output one JavaScript script each turn, and it can queue multiple tool calls.
|
||||
2. **Action Script Per Turn**: You can output one JavaScript script each turn, and it can execute multiple tool calls.
|
||||
3. **Interruption**: The world is real-time. Events (chat, damage, etc.) may happen *while* you are performing an action.
|
||||
- If a new critical event occurs, you may need to change your plans.
|
||||
- Feedback for your actions will arrive as a message starting with \`[FEEDBACK]\`.
|
||||
@@ -77,13 +77,11 @@ You are an autonomous agent playing Minecraft.
|
||||
- It's possible for a fresh event to reach you while you're in the middle of a action, in that case, remember the action is still running in the background.
|
||||
- If the new situation requires you to change plan, you can use the stop tool to stop background actions or initiate a new one, which will automatically replace the old one.
|
||||
- Feel free to send chats while background actions are running, it will not interrupt them.
|
||||
6. **JavaScript Scratchpad**: The \`eval\` tool is available, you may use it as a persistent scratchpad for calculations and short-term memory.
|
||||
- Values defined in \`eval\` persist across turns.
|
||||
- \`eval\` has a timeout, so avoid long-running code.
|
||||
- Prefer direct world tools for world interaction; use \`eval\` for reasoning support.
|
||||
7. **Planner Runtime**: Your script runs in a persistent JavaScript context with a timeout.
|
||||
- Tool functions (listed below) queue actions; they do not execute instantly.
|
||||
- The runtime validates queued actions before execution.
|
||||
6. **Planner Runtime**: Your script runs in a persistent JavaScript context with a timeout.
|
||||
- Tool functions (listed below) execute actions and return results.
|
||||
- Use \`await\` on tool calls when later logic depends on the result.
|
||||
- Globals refreshed every turn: \`snapshot\`, \`self\`, \`environment\`, \`social\`, \`threat\`, \`attention\`, \`event\`, \`now\`.
|
||||
- Persistent globals: \`mem\` (cross-turn memory), \`lastRun\` (this run), \`prevRun\` (previous run), \`lastAction\` (latest action result), \`log(...)\`.
|
||||
- Maximum actions per turn: 5.
|
||||
|
||||
# Available Tools
|
||||
@@ -94,22 +92,29 @@ ${toolsFormatted}
|
||||
|
||||
# Response Format
|
||||
You must respond with JavaScript only (no markdown code fences).
|
||||
Call tool functions directly to queue actions.
|
||||
If you want to do nothing, call \`skip()\`.
|
||||
Call tool functions directly.
|
||||
Use \`await\` when branching on action outcomes.
|
||||
If you want to do nothing, call \`await skip()\`.
|
||||
You can also use \`use(toolName, paramsObject)\` for dynamic tool calls.
|
||||
|
||||
Examples:
|
||||
- \`chat("hello")\`
|
||||
- \`goToPlayer("Alex", 2)\`
|
||||
- \`goToPlayer({ player_name: "Alex", closeness: 2 })\`
|
||||
- \`const steps = 3; for (let i = 0; i < steps; i++) chat("step " + i)\`
|
||||
- \`skip()\`
|
||||
- \`await chat("hello")\`
|
||||
- \`const sent = await chat("HP=" + self.health); log(sent)\`
|
||||
- \`const arrived = await goToPlayer({ player_name: "Alex", closeness: 2 }); if (!arrived) await chat("failed")\`
|
||||
- \`if (self.health < 10) await consume({ item_name: "bread" })\`
|
||||
- \`await skip()\`
|
||||
|
||||
# Usage Convention (Important)
|
||||
- Plan with \`mem.plan\`, execute in small steps, and verify each step before continuing.
|
||||
- Treat action results as potentially unreliable; check outcomes against \`snapshot\`/feedback before committing to the next step.
|
||||
- Prefer deterministic scripts: no random branching unless needed.
|
||||
- Keep per-turn scripts short and focused on one tactical objective.
|
||||
|
||||
# Rules
|
||||
- **Native Reasoning**: You can think before outputting your action.
|
||||
- **Strict JavaScript Output**: Output ONLY executable JavaScript. Comments are possible but discouraged and will be ignored.
|
||||
- **Handling Feedback**: When you perform an action, you will see a \`[FEEDBACK]\` message in the history later with the result. Use this to verify success.
|
||||
- **Tool Choice**: If a dedicated tool exists for a task, use it. Use \`eval\` for computation, scratch memory, or inspecting previously stored variables.
|
||||
- **Tool Choice**: If a dedicated tool exists for a task, use it.
|
||||
- **Skip Rule**: If you call \`skip()\`, do not call any other tool in the same turn.
|
||||
`
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { JavaScriptRepl } from './javascript-repl'
|
||||
|
||||
describe('javascript-repl', () => {
|
||||
it('persists variables between eval calls', () => {
|
||||
const repl = new JavaScriptRepl()
|
||||
|
||||
repl.evaluate('const var_foo = 6 * 7')
|
||||
|
||||
expect(repl.evaluate('var_foo')).toBe('42')
|
||||
})
|
||||
|
||||
it('times out long-running scripts', () => {
|
||||
const repl = new JavaScriptRepl({ timeoutMs: 20 })
|
||||
|
||||
expect(() => repl.evaluate('while (true) {}')).toThrow(/Script execution timed out/i)
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { inspect } from 'node:util'
|
||||
import vm from 'node:vm'
|
||||
|
||||
interface JavaScriptReplOptions {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export class JavaScriptRepl {
|
||||
private readonly context: vm.Context
|
||||
private readonly timeoutMs: number
|
||||
|
||||
constructor(options: JavaScriptReplOptions = {}) {
|
||||
this.timeoutMs = options.timeoutMs ?? 500
|
||||
this.context = vm.createContext({})
|
||||
}
|
||||
|
||||
public evaluate(code: string): string {
|
||||
const script = new vm.Script(code)
|
||||
const result = script.runInContext(this.context, { timeout: this.timeoutMs })
|
||||
|
||||
if (result === undefined)
|
||||
return 'undefined'
|
||||
|
||||
return typeof result === 'string'
|
||||
? result
|
||||
: inspect(result, { depth: 4, maxArrayLength: 100, breakLength: 120 })
|
||||
}
|
||||
}
|
||||
|
||||
export const javascriptRepl = new JavaScriptRepl()
|
||||
Reference in New Issue
Block a user