Skip to content

Commit 1384161

Browse files
shinohara-rinnekomeowww
authored andcommitted
feat(minecraft): add no-action follow-up system to prevent silent observation loops
Add NO_ACTION_FOLLOWUP_SOURCE_ID constant, implement queueNoActionFollowup method that schedules system_alert event with no_actions reason when planner returns zero actions during observation mode, suppress follow-up if already in follow-up chain to prevent infinite loops, update brain-prompt with feedback loop guard guidance to avoid chat->feedback->chat cycles and clarify feedback:true usage for diagnostic verification only
1 parent f70f9fa commit 1384161

File tree

4 files changed

+180
-5
lines changed

4 files changed

+180
-5
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
import { Brain } from './brain'
4+
5+
function createReflexSnapshot() {
6+
return {
7+
self: {
8+
health: 20,
9+
food: 20,
10+
holding: null,
11+
location: { x: 0, y: 64, z: 0 },
12+
},
13+
environment: {
14+
time: 'day',
15+
weather: 'clear',
16+
nearbyPlayers: [],
17+
nearbyEntities: [],
18+
lightLevel: 15,
19+
nearbyPlayersGaze: [],
20+
},
21+
social: {},
22+
threat: {},
23+
attention: {},
24+
autonomy: {
25+
followPlayer: null,
26+
followActive: false,
27+
},
28+
}
29+
}
30+
31+
function createDeps(llmText: string) {
32+
const logger = {
33+
log: vi.fn(),
34+
warn: vi.fn(),
35+
error: vi.fn(),
36+
withError: vi.fn(),
37+
} as any
38+
logger.withError.mockReturnValue(logger)
39+
40+
return {
41+
eventBus: { subscribe: vi.fn() },
42+
llmAgent: {
43+
callLLM: vi.fn(async () => ({ text: llmText, reasoning: '', usage: {} })),
44+
},
45+
logger,
46+
taskExecutor: {
47+
getAvailableActions: vi.fn(() => []),
48+
executeActionWithResult: vi.fn(async () => 'ok'),
49+
on: vi.fn(),
50+
},
51+
reflexManager: {
52+
getContextSnapshot: vi.fn(() => createReflexSnapshot()),
53+
clearFollowTarget: vi.fn(),
54+
},
55+
} as any
56+
}
57+
58+
function createPerceptionEvent() {
59+
return {
60+
type: 'perception',
61+
payload: {
62+
type: 'chat_message',
63+
description: 'Chat from Alex: "hi"',
64+
sourceId: 'Alex',
65+
confidence: 1,
66+
timestamp: Date.now(),
67+
metadata: { username: 'Alex', message: 'hi' },
68+
},
69+
source: { type: 'minecraft', id: 'Alex' },
70+
timestamp: Date.now(),
71+
} as any
72+
}
73+
74+
describe('brain no-action follow-up', () => {
75+
it('queues exactly one synthetic follow-up on no-action result', async () => {
76+
const brain: any = new Brain(createDeps('1 + 1'))
77+
const enqueueSpy = vi.fn(async () => undefined)
78+
brain.enqueueEvent = enqueueSpy
79+
80+
await brain.processEvent({} as any, createPerceptionEvent())
81+
82+
expect(enqueueSpy).toHaveBeenCalledTimes(1)
83+
const queuedEvent = (enqueueSpy.mock.calls[0] as any[])?.[1]
84+
expect(queuedEvent).toMatchObject({
85+
type: 'system_alert',
86+
source: { type: 'system', id: 'brain:no_action_followup' },
87+
payload: { reason: 'no_actions' },
88+
})
89+
})
90+
91+
it('does not chain follow-up from follow-up event source', async () => {
92+
const brain: any = new Brain(createDeps('1 + 1'))
93+
const enqueueSpy = vi.fn(async () => undefined)
94+
brain.enqueueEvent = enqueueSpy
95+
96+
await brain.processEvent({} as any, {
97+
type: 'system_alert',
98+
payload: { reason: 'seed' },
99+
source: { type: 'system', id: 'brain:no_action_followup' },
100+
timestamp: Date.now(),
101+
})
102+
103+
expect(enqueueSpy).not.toHaveBeenCalled()
104+
})
105+
106+
it('does not queue follow-up when script uses skip()', async () => {
107+
const brain: any = new Brain(createDeps('await skip()'))
108+
const enqueueSpy = vi.fn(async () => undefined)
109+
brain.enqueueEvent = enqueueSpy
110+
111+
await brain.processEvent({} as any, createPerceptionEvent())
112+
113+
expect(enqueueSpy).not.toHaveBeenCalled()
114+
})
115+
})

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ function truncateForPrompt(value: string, maxLength = 220): string {
7777
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}...`
7878
}
7979

80+
const NO_ACTION_FOLLOWUP_SOURCE_ID = 'brain:no_action_followup'
81+
8082
export class Brain {
8183
private debugService: DebugService
8284
private readonly planner = new JavaScriptPlanner()
@@ -273,6 +275,35 @@ export class Brain {
273275
return JSON.parse(JSON.stringify(messages)) as Message[]
274276
}
275277

278+
private queueNoActionFollowup(
279+
bot: MineflayerWithAgents,
280+
triggeringEvent: BotEvent,
281+
returnValue: string | undefined,
282+
logs: string[],
283+
): void {
284+
if (triggeringEvent.source.type === 'system' && triggeringEvent.source.id === NO_ACTION_FOLLOWUP_SOURCE_ID) {
285+
this.deps.logger.log('INFO', 'Brain: Suppressed no-action follow-up (already in follow-up chain)')
286+
this.debugService.log('DEBUG', 'No-action follow-up suppressed (already follow-up source)')
287+
return
288+
}
289+
290+
const followupEvent: BotEvent = {
291+
type: 'system_alert',
292+
payload: {
293+
reason: 'no_actions',
294+
returnValue: returnValue ?? 'undefined',
295+
logs: logs.slice(-3),
296+
},
297+
source: { type: 'system', id: NO_ACTION_FOLLOWUP_SOURCE_ID },
298+
timestamp: Date.now(),
299+
}
300+
301+
this.debugService.log('DEBUG', 'Scheduling one-hop no-action follow-up turn')
302+
void this.enqueueEvent(bot, followupEvent).catch(err =>
303+
this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action follow-up'),
304+
)
305+
}
306+
276307
// --- Event Queue Logic ---
277308

278309
private async enqueueEvent(bot: MineflayerWithAgents, event: BotEvent): Promise<void> {
@@ -485,6 +516,9 @@ export class Brain {
485516
durationMs: 0,
486517
timestamp: Date.now(),
487518
})
519+
if (runResult.actions.length === 0) {
520+
this.queueNoActionFollowup(bot, event, runResult.returnValue, runResult.logs)
521+
}
488522
this.deps.logger.log('INFO', 'Brain: Skipping turn (observing)')
489523
return
490524
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { z } from 'zod'
3+
4+
import { generateBrainSystemPrompt } from './brain-prompt'
5+
6+
describe('generateBrainSystemPrompt', () => {
7+
it('includes chat feedback loop guard guidance', () => {
8+
const prompt = generateBrainSystemPrompt([
9+
{
10+
name: 'chat',
11+
description: 'Send a chat message',
12+
execution: 'sync',
13+
schema: z.object({ message: z.string(), feedback: z.boolean().optional() }),
14+
perform: () => () => '',
15+
},
16+
] as any)
17+
18+
expect(prompt).toContain('Feedback Loop Guard')
19+
expect(prompt).toContain('chat->feedback->chat')
20+
})
21+
})

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ import type { Action } from '../../../libs/mineflayer/action'
22

33
// Helper to extract readable type from Zod schema
44
function getZodTypeName(def: any): string {
5-
if (!def) return 'any'
5+
if (!def)
6+
return 'any'
67
const type = def.type || def.typeName
78

8-
if (type === 'string' || type === 'ZodString') return 'string'
9-
if (type === 'number' || type === 'ZodNumber') return 'number'
10-
if (type === 'boolean' || type === 'ZodBoolean') return 'boolean'
9+
if (type === 'string' || type === 'ZodString')
10+
return 'string'
11+
if (type === 'number' || type === 'ZodNumber')
12+
return 'number'
13+
if (type === 'boolean' || type === 'ZodBoolean')
14+
return 'boolean'
1115

1216
if (type === 'array' || type === 'ZodArray') {
1317
const innerDef = def.element?._def || def.type?._def
@@ -194,7 +198,8 @@ Common patterns:
194198
- **Chat Discipline**: Do not send proactive small-talk. Use \`chat\` only when replying to a player chat, reporting meaningful task progress/failure, or urgent safety status.
195199
- **No Harness Replies**: Never treat \`[PERCEPTION]\`, \`[FEEDBACK]\`, or other system wrappers as players. Only reply with \`chat\` to actual player \`chat_message\` events.
196200
- **No Self Replies**: Never reply to your own previous bot messages.
197-
- **Chat Feedback**: \`chat\` feedback is optional; keep \`feedback: false\` for normal conversation. Use \`feedback: true\` only when your next step explicitly needs the chat acknowledgement in history.
201+
- **Chat Feedback**: \`chat\` feedback is optional; keep \`feedback: false\` for normal conversation. Use \`feedback: true\` only for diagnostic verification of a sent chat.
202+
- **Feedback Loop Guard**: Avoid chat->feedback->chat positive loops. After a diagnostic \`feedback: true\` check, usually continue with \`skip()\` unless the returned feedback is unexpected and needs action.
198203
- **Follow Mode**: If \`autonomy.followPlayer\` is set, reflex will follow that player while idle. Only clear it when the current mission needs independent movement.
199204
`
200205
}

0 commit comments

Comments
 (0)