Skip to content

Commit 6d7db23

Browse files
shinohara-rinnekomeowww
authored andcommitted
feat(minecraft): expect guardrails and a working gaze api
Add toCoord/cloneVec3 helpers, return structured telemetry from goToPlayer/goToCoordinate with ok/startPos/endPos/movedDistance/distanceToTargetBefore/distanceToTargetAfter fields, track lastPlannerOutcome summary (actionCount/okCount/errorCount/returnValue/logs) in Brain and inject as [SCRIPT] context with truncation, implement expect/expectMoved/expectNear guardrail tools in JavaScriptPlanner to validate action
1 parent 7438361 commit 6d7db23

File tree

7 files changed

+291
-6
lines changed

7 files changed

+291
-6
lines changed

services/minecraft/src/cognitive/action/llm-actions.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ function formatWearingItem(slot: string, item: string | undefined): string {
2424
return item ? `\n${slot}: ${item}` : ''
2525
}
2626

27+
function toCoord(pos: { x: number, y: number, z: number }) {
28+
return { x: pos.x, y: pos.y, z: pos.z }
29+
}
30+
31+
function cloneVec3(pos: { x: number, y: number, z: number }): Vec3 {
32+
return new Vec3(pos.x, pos.y, pos.z)
33+
}
34+
2735
export const actionsList: Action[] = [
2836
{
2937
name: 'chat',
@@ -147,9 +155,31 @@ export const actionsList: Action[] = [
147155
closeness: z.number().describe('How close to get to the player in blocks.').min(0),
148156
}),
149157
perform: mineflayer => async (player_name: string, closeness: number) => {
158+
const getPlayerPos = () => {
159+
const entity = mineflayer.bot.players[player_name]?.entity
160+
return entity ? cloneVec3(entity.position) : null
161+
}
162+
163+
const selfStart = cloneVec3(mineflayer.bot.entity.position)
164+
const targetStart = getPlayerPos()
165+
const distanceToTargetBefore = targetStart ? selfStart.distanceTo(targetStart) : null
166+
150167
// TODO estimate time cost based on distance, trigger failure if time runs out
151-
await skills.goToPlayer(mineflayer, player_name, closeness)
152-
return `Arrived at player [${player_name}]`
168+
const ok = await skills.goToPlayer(mineflayer, player_name, closeness)
169+
170+
const selfEnd = cloneVec3(mineflayer.bot.entity.position)
171+
const targetEnd = getPlayerPos()
172+
const distanceToTargetAfter = targetEnd ? selfEnd.distanceTo(targetEnd) : null
173+
174+
return {
175+
ok,
176+
target: { player_name, closeness },
177+
startPos: toCoord(selfStart),
178+
endPos: toCoord(selfEnd),
179+
movedDistance: selfStart.distanceTo(selfEnd),
180+
distanceToTargetBefore,
181+
distanceToTargetAfter,
182+
}
153183
},
154184
},
155185
{
@@ -197,8 +227,25 @@ export const actionsList: Action[] = [
197227
closeness: z.number().describe('0 If want to be exactly at the position, otherwise a positive number in blocks for leniency.').min(0),
198228
}),
199229
perform: mineflayer => async (x: number, y: number, z: number, closeness: number) => {
200-
await skills.goToPosition(mineflayer, x, y, z, closeness)
201-
return `Arrived at coordinate [${x}, ${y}, ${z}]`
230+
const selfStart = cloneVec3(mineflayer.bot.entity.position)
231+
const targetVec = new Vec3(x, y, z)
232+
const distanceToTargetBefore = selfStart.distanceTo(targetVec)
233+
234+
const ok = await skills.goToPosition(mineflayer, x, y, z, closeness)
235+
236+
const selfEnd = cloneVec3(mineflayer.bot.entity.position)
237+
const distanceToTargetAfter = selfEnd.distanceTo(targetVec)
238+
239+
return {
240+
ok,
241+
target: { x, y, z, closeness },
242+
startPos: toCoord(selfStart),
243+
endPos: toCoord(selfEnd),
244+
movedDistance: selfStart.distanceTo(selfEnd),
245+
distanceToTargetBefore,
246+
distanceToTargetAfter,
247+
withinCloseness: distanceToTargetAfter <= closeness,
248+
}
202249
},
203250
},
204251
{

services/minecraft/src/cognitive/conscious/brain.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ interface QueuedEvent {
3636
reject: (err: Error) => void
3737
}
3838

39+
interface PlannerOutcomeSummary {
40+
actionCount: number
41+
okCount: number
42+
errorCount: number
43+
returnValue?: string
44+
logs: string[]
45+
updatedAt: number
46+
}
47+
48+
function truncateForPrompt(value: string, maxLength = 220): string {
49+
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}...`
50+
}
51+
3952
export class Brain {
4053
private debugService: DebugService
4154
private readonly planner = new JavaScriptPlanner()
@@ -49,6 +62,7 @@ export class Brain {
4962
private lastHumanChatAt = 0
5063
private botUsername = ''
5164
private lastContextView: string | undefined
65+
private lastPlannerOutcome: PlannerOutcomeSummary | undefined
5266
private conversationHistory: Message[] = []
5367

5468
constructor(private readonly deps: BrainDeps) {
@@ -285,6 +299,15 @@ export class Brain {
285299
},
286300
)
287301

302+
this.lastPlannerOutcome = {
303+
actionCount: runResult.actions.length,
304+
okCount: runResult.actions.filter(item => item.ok).length,
305+
errorCount: runResult.actions.filter(item => !item.ok).length,
306+
returnValue: runResult.returnValue,
307+
logs: runResult.logs.slice(-3),
308+
updatedAt: Date.now(),
309+
}
310+
288311
if (runResult.actions.length === 0 || runResult.actions.every(item => item.action.tool === 'skip')) {
289312
this.deps.logger.log('INFO', 'Brain: Skipping turn (observing)')
290313
return
@@ -347,7 +370,16 @@ export class Brain {
347370
parts.push(`[STATE] giveUp active (${remainingSec}s left). reason=${this.giveUpReason ?? 'unknown'}`)
348371
}
349372

350-
parts.push('[RUNTIME] Globals are refreshed every turn: snapshot, self, environment, social, threat, attention, autonomy, event, now, mem, lastRun, lastAction. Player gaze is available in environment.nearbyPlayersGaze when needed.')
373+
if (this.lastPlannerOutcome) {
374+
const ageMs = Date.now() - this.lastPlannerOutcome.updatedAt
375+
const returnValue = truncateForPrompt(this.lastPlannerOutcome.returnValue ?? 'undefined')
376+
const logs = this.lastPlannerOutcome.logs.length > 0
377+
? this.lastPlannerOutcome.logs.map((line, index) => `#${index + 1} ${truncateForPrompt(line, 120)}`).join(' | ')
378+
: '(none)'
379+
parts.push(`[SCRIPT] Last eval ${ageMs}ms ago: return=${returnValue}; actions=${this.lastPlannerOutcome.actionCount} (ok=${this.lastPlannerOutcome.okCount}, err=${this.lastPlannerOutcome.errorCount}); logs=${logs}`)
380+
}
381+
382+
parts.push('[RUNTIME] Globals are refreshed every turn: snapshot, self, environment, social, threat, attention, autonomy, event, now, mem, lastRun, prevRun, lastAction. Player gaze is available in environment.nearbyPlayersGaze when needed.')
351383

352384
return parts.join('\n\n')
353385
}

services/minecraft/src/cognitive/conscious/js-planner.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,38 @@ describe('JavaScriptPlanner', () => {
112112

113113
await expect(planner.evaluate('while (true) {}', actions, globals, executeAction)).rejects.toThrow(/Script execution timed out/i)
114114
})
115+
116+
it('supports expectation guardrails on structured action telemetry', async () => {
117+
const planner = new JavaScriptPlanner()
118+
const executeAction = vi.fn(async () => ({
119+
ok: true,
120+
movedDistance: 1.25,
121+
distanceToTargetAfter: 1.5,
122+
endPos: { x: 8, y: 64, z: 4 },
123+
}))
124+
125+
const planned = await planner.evaluate(`
126+
const nav = await goToPlayer({ player_name: "Alex", closeness: 2 })
127+
expect(nav.ok, "go failed")
128+
expectMoved(1)
129+
expectNear(2)
130+
expectNear({ x: 7, y: 64, z: 4 }, 2)
131+
`, actions, globals, executeAction)
132+
133+
expect(planned.actions).toHaveLength(1)
134+
expect(planned.actions[0]?.ok).toBe(true)
135+
})
136+
137+
it('throws when expectation guardrail fails', async () => {
138+
const planner = new JavaScriptPlanner()
139+
const executeAction = vi.fn(async () => ({
140+
ok: true,
141+
movedDistance: 0.1,
142+
}))
143+
144+
await expect(planner.evaluate(`
145+
await goToPlayer({ player_name: "Alex", closeness: 2 })
146+
expectMoved(1, "did not move enough")
147+
`, actions, globals, executeAction)).rejects.toThrow(/Expectation failed: did not move enough/i)
148+
})
115149
})

services/minecraft/src/cognitive/conscious/js-planner.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
3535
return typeof value === 'object' && value !== null && !Array.isArray(value)
3636
}
3737

38+
function isCoord(value: unknown): value is { x: number, y: number, z: number } {
39+
return isRecord(value)
40+
&& typeof value.x === 'number'
41+
&& typeof value.y === 'number'
42+
&& typeof value.z === 'number'
43+
}
44+
3845
function deepFreeze<T>(value: T): T {
3946
if (!value || typeof value !== 'object')
4047
return value
@@ -114,6 +121,10 @@ export class JavaScriptPlanner {
114121
? undefined
115122
: inspect(result, { depth: 2, breakLength: 100 })
116123

124+
if (isRecord(this.sandbox.lastRun)) {
125+
this.sandbox.lastRun.returnValue = returnValue
126+
}
127+
117128
return {
118129
actions: run.executed,
119130
logs: run.logs,
@@ -143,9 +154,93 @@ export class JavaScriptPlanner {
143154
this.activeRun.logs.push(rendered)
144155
return rendered
145156
})
157+
this.defineGlobalTool('expect', (condition: unknown, message?: unknown) => {
158+
if (condition)
159+
return true
160+
161+
const detail = typeof message === 'string' && message.trim().length > 0
162+
? message
163+
: 'Condition evaluated to false'
164+
throw new Error(`Expectation failed: ${detail}`)
165+
})
166+
this.defineGlobalTool('expectMoved', (minBlocks?: unknown, message?: unknown) => {
167+
const threshold = typeof minBlocks === 'number' ? minBlocks : 0.5
168+
const telemetry = this.getLastActionResultRecord()
169+
const movedDistance = typeof telemetry?.movedDistance === 'number'
170+
? telemetry.movedDistance
171+
: null
172+
173+
if (movedDistance === null) {
174+
throw new Error('Expectation failed: expectMoved() requires last action result with movedDistance telemetry')
175+
}
176+
177+
if (movedDistance >= threshold)
178+
return true
179+
180+
const detail = typeof message === 'string' && message.trim().length > 0
181+
? message
182+
: `Expected movedDistance >= ${threshold}, got ${movedDistance}`
183+
throw new Error(`Expectation failed: ${detail}`)
184+
})
185+
this.defineGlobalTool('expectNear', (targetOrMaxDist?: unknown, maxDistOrMessage?: unknown, maybeMessage?: unknown) => {
186+
const telemetry = this.getLastActionResultRecord()
187+
188+
let target: { x: number, y: number, z: number } | null = null
189+
let maxDist = 2
190+
let message: string | undefined
191+
192+
if (isCoord(targetOrMaxDist)) {
193+
target = { x: targetOrMaxDist.x, y: targetOrMaxDist.y, z: targetOrMaxDist.z }
194+
if (typeof maxDistOrMessage === 'number')
195+
maxDist = maxDistOrMessage
196+
if (typeof maybeMessage === 'string')
197+
message = maybeMessage
198+
}
199+
else {
200+
if (typeof targetOrMaxDist === 'number')
201+
maxDist = targetOrMaxDist
202+
if (typeof maxDistOrMessage === 'string')
203+
message = maxDistOrMessage
204+
}
205+
206+
let distance: number | null = null
207+
if (target) {
208+
const endPos = isCoord(telemetry?.endPos) ? telemetry.endPos : null
209+
if (!endPos) {
210+
throw new Error('Expectation failed: expectNear(target) requires last action result with endPos telemetry')
211+
}
212+
213+
const dx = endPos.x - target.x
214+
const dy = endPos.y - target.y
215+
const dz = endPos.z - target.z
216+
distance = Math.sqrt(dx * dx + dy * dy + dz * dz)
217+
}
218+
else if (typeof telemetry?.distanceToTargetAfter === 'number') {
219+
distance = telemetry.distanceToTargetAfter
220+
}
221+
222+
if (distance === null) {
223+
throw new Error('Expectation failed: expectNear() requires target argument or last action distanceToTargetAfter telemetry')
224+
}
225+
226+
if (distance <= maxDist)
227+
return true
228+
229+
const detail = message ?? `Expected distance <= ${maxDist}, got ${distance}`
230+
throw new Error(`Expectation failed: ${detail}`)
231+
})
146232
this.defineGlobalValue('mem', {})
147233
}
148234

235+
private getLastActionResultRecord(): Record<string, unknown> | null {
236+
const lastAction = this.sandbox.lastAction
237+
if (!isRecord(lastAction))
238+
return null
239+
240+
const result = lastAction.result
241+
return isRecord(result) ? result : null
242+
}
243+
149244
private installActionTools(availableActions: Action[]): void {
150245
for (const action of availableActions) {
151246
this.defineGlobalTool(action.name, async (...args: unknown[]) => {

services/minecraft/src/cognitive/conscious/prompts/brain-prompt.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ You are an autonomous agent playing Minecraft.
108108
- Use \`await\` on tool calls when later logic depends on the result.
109109
- Globals refreshed every turn: \`snapshot\`, \`self\`, \`environment\`, \`social\`, \`threat\`, \`attention\`, \`autonomy\`, \`event\`, \`now\`.
110110
- Persistent globals: \`mem\` (cross-turn memory), \`lastRun\` (this run), \`prevRun\` (previous run), \`lastAction\` (latest action result), \`log(...)\`.
111+
- Last script outcome is also echoed in the next turn as \`[SCRIPT]\` context (return value, action stats, and logs).
111112
- Maximum actions per turn: 5.
112113
113114
# Environment & Global Semantics
@@ -142,13 +143,37 @@ Call tool functions directly.
142143
Use \`await\` when branching on action outcomes.
143144
If you want to do nothing, call \`await skip()\`.
144145
You can also use \`use(toolName, paramsObject)\` for dynamic tool calls.
146+
Use built-in guardrails to verify outcomes: \`expect(...)\`, \`expectMoved(...)\`, \`expectNear(...)\`.
145147
146148
Examples:
147149
- \`await chat("hello")\`
148150
- \`const sent = await chat("HP=" + self.health); log(sent)\`
149151
- \`const arrived = await goToPlayer({ player_name: "Alex", closeness: 2 }); if (!arrived) await chat("failed")\`
150152
- \`if (self.health < 10) await consume({ item_name: "bread" })\`
151153
- \`await skip()\`
154+
- \`const nav = await goToCoordinate({ x: 12, y: 64, z: -5, closeness: 2 }); expect(nav.ok, "navigation failed"); expectMoved(0.8); expectNear(2.5)\`
155+
156+
Guardrail semantics:
157+
- \`expect(condition, message?)\`: throw if condition is falsy.
158+
- \`expectMoved(minBlocks = 0.5, message?)\`: checks last action telemetry \`movedDistance\`.
159+
- \`expectNear(targetOrMaxDist = 2, maxDist?, message?)\`:
160+
- \`expectNear(2.5)\` uses last action telemetry \`distanceToTargetAfter\`.
161+
- \`expectNear({ x, y, z }, 2)\` uses last action telemetry \`endPos\`.
162+
163+
Common patterns:
164+
- Follow + detach for exploration:
165+
- \`await followPlayer({ player_name: "laggy_magpie", follow_dist: 2 })\`
166+
- \`const nav = await goToCoordinate({ x: 120, y: 70, z: -30, closeness: 2 }) // detaches follow automatically\`
167+
- \`expect(nav.ok, "failed to reach exploration point")\`
168+
- Confirm movement before claiming progress:
169+
- \`const r = await goToPlayer({ player_name: "Alex", closeness: 2 })\`
170+
- \`expect(r.ok, "goToPlayer failed")\`
171+
- \`expectMoved(1, "I did not actually move")\`
172+
- \`expectNear(3, "still too far from player")\`
173+
- Gaze as weak hint only:
174+
- \`const gaze = environment.nearbyPlayersGaze.find(g => g.name === "Alex")\`
175+
- \`if (event.type === "perception" && event.payload?.type === "chat_message" && gaze?.hitBlock)\`
176+
- \` await goToCoordinate({ x: gaze.hitBlock.pos.x, y: gaze.hitBlock.pos.y, z: gaze.hitBlock.pos.z, closeness: 2 })\`
152177
153178
# Usage Convention (Important)
154179
- Plan with \`mem.plan\`, execute in small steps, and verify each step before continuing.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { Vec3 } from 'vec3'
3+
4+
import { rayTraceBlockFromEntity } from './gaze'
5+
6+
describe('rayTraceBlockFromEntity', () => {
7+
it('detects block when player looks downward', () => {
8+
const bot = {
9+
blockAt(pos: Vec3) {
10+
if (pos.x === 0 && pos.y === 63 && pos.z === -3) {
11+
return {
12+
name: 'grass_block',
13+
position: new Vec3(0, 63, -3),
14+
}
15+
}
16+
return { name: 'air', position: pos }
17+
},
18+
} as any
19+
20+
const entity = {
21+
type: 'player',
22+
username: 'tester',
23+
position: new Vec3(0, 64, 0),
24+
yaw: 0,
25+
pitch: Math.PI / 4,
26+
}
27+
28+
const result = rayTraceBlockFromEntity(bot, entity, { maxDistance: 8, step: 0.1 })
29+
expect(result.hitBlock?.name).toBe('grass_block')
30+
expect(result.hitBlock?.pos).toEqual({ x: 0, y: 63, z: -3 })
31+
})
32+
33+
it('returns null hitBlock when no solid block is intersected', () => {
34+
const bot = {
35+
blockAt(pos: Vec3) {
36+
return { name: 'air', position: pos }
37+
},
38+
} as any
39+
40+
const entity = {
41+
type: 'player',
42+
username: 'tester',
43+
position: new Vec3(0, 64, 0),
44+
yaw: 0,
45+
pitch: -Math.PI / 4,
46+
}
47+
48+
const result = rayTraceBlockFromEntity(bot, entity, { maxDistance: 8, step: 0.1 })
49+
expect(result.hitBlock).toBeNull()
50+
})
51+
})

0 commit comments

Comments
 (0)