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:
@@ -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,10 +275,15 @@ export class JavaScriptPlanner {
|
||||
if (!action)
|
||||
throw new Error(`Unknown tool: ${tool}`)
|
||||
|
||||
return {
|
||||
tool,
|
||||
params: action.schema.parse(params),
|
||||
const parsed = action.schema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
const details = parsed.error.issues.map(issue => `${issue.path.join('.') || 'root'}: ${issue.message}`).join('; ')
|
||||
return {
|
||||
error: `Invalid tool parameters for ${tool}: ${details}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { action: { tool, params: parsed.data } }
|
||||
}
|
||||
|
||||
private defineGlobalTool(name: string, fn: (...args: unknown[]) => unknown): void {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user