refactor(minecraft): change action return types from string to unknown and improve search actions

Replace string return type with unknown in ActionRegistry/TaskExecutor to support structured responses, change searchForBlock/searchForEntity from navigation to pure search returning structured data with found/block/entity/distance fields, add validation result handling in js-planner to return failed validations as ActionRuntimeResult instead of throwing, use nullish coalescing (??) instead of OR (
This commit is contained in:
Rin
2026-02-18 11:14:37 +08:00
committed by Neko Ayaka
parent e69e6608b8
commit 9e31bf33d5
7 changed files with 136 additions and 30 deletions
@@ -32,7 +32,7 @@ export class ActionRegistry {
/**
* Perform an action by name
*/
public async performAction(step: { description?: string, tool: string, params: any }): Promise<string> {
public async performAction(step: { description?: string, tool: string, params: any }): Promise<unknown> {
if (!this.mineflayer) {
throw new Error('Mineflayer instance not set in ActionRegistry')
}
@@ -51,7 +51,7 @@ export class ActionRegistry {
const paramValues = Object.keys((schema as any).shape || {}).map(key => parsedParams[key])
const result = await actionFn(...paramValues)
return result || `Action ${step.tool} completed`
return result ?? `Action ${step.tool} completed`
}
catch (error) {
throw error
@@ -181,28 +181,72 @@ export const actionsList: Action[] = [
},
{
name: 'searchForBlock',
description: 'Find and go to the nearest block of a given type in a given range.',
description: 'Find the nearest block of a given type in a given range and return its coordinates.',
execution: 'async',
schema: z.object({
type: z.string().describe('The block type to go to.'),
search_range: z.number().describe('The range to search for the block.').min(32).max(512),
type: z.string().describe('The block type to search for.'),
search_range: z.number().describe('The range to search for the block.').min(1).max(512),
}),
perform: mineflayer => async (block_type: string, range: number) => {
const block = await skills.goToNearestBlock(mineflayer, block_type, 4, range)
return `Arrived at nearest [${block.name}] at (${block.position.x}, ${block.position.y}, ${block.position.z})` // TODO more spacial context?
const block = world.getNearestBlock(mineflayer, block_type, range)
if (!block) {
return {
found: false,
query: { type: block_type, range },
}
}
const distance = mineflayer.bot.entity.position.distanceTo(block.position)
return {
found: true,
block: {
name: block.name,
position: {
x: block.position.x,
y: block.position.y,
z: block.position.z,
},
},
distance,
}
},
},
{
name: 'searchForEntity',
description: 'Find and go to the nearest entity of a given type in a given range.',
description: 'Find the nearest entity of a given type in a given range and return its coordinates.',
execution: 'async',
schema: z.object({
type: z.string().describe('The type of entity to go to.'),
search_range: z.number().describe('The range to search for the entity.').min(32).max(512),
type: z.string().describe('The type of entity to search for.'),
search_range: z.number().describe('The range to search for the entity.').min(1).max(512),
}),
perform: mineflayer => async (entity_type: string, range: number) => {
await skills.goToNearestEntity(mineflayer, entity_type, 4, range)
return `Arrived at nearest [${entity_type}]`
const entity = world.getNearestEntityWhere(
mineflayer,
current => current.name === entity_type,
range,
)
if (!entity) {
return {
found: false,
query: { type: entity_type, range },
}
}
const distance = mineflayer.bot.entity.position.distanceTo(entity.position)
return {
found: true,
entity: {
name: entity.name,
type: entity.type,
position: {
x: entity.position.x,
y: entity.position.y,
z: entity.position.z,
},
},
distance,
}
},
},
// {
@@ -53,7 +53,7 @@ export class TaskExecutor extends EventEmitter {
}
}
public async executeActionWithResult(action: ActionInstruction, cancellationToken?: CancellationToken): Promise<string | void> {
public async executeActionWithResult(action: ActionInstruction, cancellationToken?: CancellationToken): Promise<unknown> {
if (!this.initialized) {
throw new Error('TaskExecutor not initialized')
}
@@ -66,11 +66,11 @@ export class TaskExecutor extends EventEmitter {
return this.runSingleAction(action)
}
private async runSingleAction(action: ActionInstruction): Promise<string | void> {
private async runSingleAction(action: ActionInstruction): Promise<unknown> {
this.emit('action:started', { action })
try {
let result: string | void
let result: unknown
if (action.tool === 'chat') {
// Handle chat action via mineflayer directly
@@ -90,6 +90,22 @@ describe('JavaScriptPlanner', () => {
await expect(planner.evaluate('await skip(); await chat("oops")', actions, globals, executeAction)).rejects.toThrow(/skip\(\) cannot be mixed/i)
})
it('returns structured validation failures without aborting the script', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
const planned = await planner.evaluate(`
const first = await goToPlayer({ player_name: "Alex", closeness: -1 })
if (!first.ok) {
await chat("fallback")
}
`, actions, globals, executeAction)
expect(planned.actions[0]?.ok).toBe(false)
expect(planned.actions[0]?.error).toMatch(/Invalid tool parameters/i)
expect(executeAction).toHaveBeenCalledTimes(1)
expect(planned.actions[1]?.action.tool).toBe('chat')
})
it('enforces timeout on long-running scripts', async () => {
const planner = new JavaScriptPlanner({ timeoutMs: 20 })
const executeAction = vi.fn(async action => `ok:${action.tool}`)
@@ -13,19 +13,24 @@ interface JavaScriptPlannerOptions {
interface ActionRuntimeResult {
action: ActionInstruction
ok: boolean
result?: string
result?: unknown
error?: string
}
interface ActivePlannerRun {
actionCount: number
actionsByName: Map<string, Action>
executeAction: (action: ActionInstruction) => Promise<string | void>
executeAction: (action: ActionInstruction) => Promise<unknown>
executed: ActionRuntimeResult[]
logs: string[]
sawSkip: boolean
}
interface ValidationResult {
action?: ActionInstruction
error?: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -85,7 +90,7 @@ export class JavaScriptPlanner {
content: string,
availableActions: Action[],
globals: RuntimeGlobals,
executeAction: (action: ActionInstruction) => Promise<string | void>,
executeAction: (action: ActionInstruction) => Promise<unknown>,
): Promise<JavaScriptRunResult> {
const script = extractJavaScriptCandidate(content)
const run: ActivePlannerRun = {
@@ -212,13 +217,10 @@ export class JavaScriptPlanner {
this.activeRun.sawSkip = true
}
const action = tool === 'skip'
? { tool: 'skip', params: {} as Record<string, unknown> }
: this.validateAction(tool, params)
this.activeRun.actionCount++
if (tool === 'skip') {
const action: ActionInstruction = { tool: 'skip', params: {} }
const runtimeResult: ActionRuntimeResult = {
action,
ok: true,
@@ -229,12 +231,25 @@ export class JavaScriptPlanner {
return runtimeResult
}
const validation = this.validateAction(tool, params)
if (!validation.action) {
const runtimeResult: ActionRuntimeResult = {
action: { tool, params },
ok: false,
error: validation.error ?? `Invalid tool parameters for ${tool}`,
}
this.activeRun.executed.push(runtimeResult)
this.sandbox.lastAction = runtimeResult
return runtimeResult
}
const action = validation.action
try {
const result = await this.activeRun.executeAction(action)
const runtimeResult: ActionRuntimeResult = {
action,
ok: true,
result: typeof result === 'string' ? result : undefined,
result,
}
this.activeRun.executed.push(runtimeResult)
this.sandbox.lastAction = runtimeResult
@@ -248,11 +263,11 @@ export class JavaScriptPlanner {
}
this.activeRun.executed.push(runtimeResult)
this.sandbox.lastAction = runtimeResult
throw error
return runtimeResult
}
}
private validateAction(tool: string, params: Record<string, unknown>): ActionInstruction {
private validateAction(tool: string, params: Record<string, unknown>): ValidationResult {
if (!this.activeRun)
throw new Error('Tool calls are only allowed during planner evaluation')
@@ -260,12 +275,17 @@ export class JavaScriptPlanner {
if (!action)
throw new Error(`Unknown tool: ${tool}`)
const parsed = action.schema.safeParse(params)
if (!parsed.success) {
const details = parsed.error.issues.map(issue => `${issue.path.join('.') || 'root'}: ${issue.message}`).join('; ')
return {
tool,
params: action.schema.parse(params),
error: `Invalid tool parameters for ${tool}: ${details}`,
}
}
return { action: { tool, params: parsed.data } }
}
private defineGlobalTool(name: string, fn: (...args: unknown[]) => unknown): void {
this.defineGlobalValue(name, fn)
}
@@ -34,6 +34,31 @@ function getZodTypeName(def: any): string {
return type || 'any'
}
function getZodConstraintHint(def: any): string {
if (!def)
return ''
const checks = Array.isArray(def.checks) ? def.checks : []
const hints: string[] = []
for (const check of checks) {
if (check?.kind === 'min' && typeof check.value === 'number') {
hints.push(`min=${check.value}`)
}
if (check?.kind === 'max' && typeof check.value === 'number') {
hints.push(`max=${check.value}`)
}
if (check?.def?.check === 'greater_than' && typeof check.def.value === 'number') {
hints.push(`min=${check.def.inclusive ? check.def.value : check.def.value + 1}`)
}
if (check?.def?.check === 'less_than' && typeof check.def.value === 'number') {
hints.push(`max=${check.def.inclusive ? check.def.value : check.def.value - 1}`)
}
}
return hints.length > 0 ? ` (${hints.join(', ')})` : ''
}
export function generateBrainSystemPrompt(availableActions: Action[]): string {
const toolsFormatted = availableActions.map((a) => {
const paramKeys = Object.keys(a.schema.shape)
@@ -45,8 +70,9 @@ export function generateBrainSystemPrompt(availableActions: Action[]): string {
params = Object.entries(a.schema.shape).map(([key, val]: [string, any]) => {
const def = val._def
const type = getZodTypeName(def)
const constraints = getZodConstraintHint(def)
const desc = val.description ? ` - ${val.description}` : ''
return ` * @param {${type}} ${key}${desc}`
return ` * @param {${type}${constraints}} ${key}${desc}`
}).join('\n')
}
@@ -2,7 +2,7 @@ import type { z } from 'zod'
import type { Mineflayer } from './core'
type ActionResult = string | Promise<string>
type ActionResult = unknown | Promise<unknown>
export interface Action {
readonly name: string