Skip to content

Commit 027ad74

Browse files
FlowerWater1019autofix-ci[bot]shinohara-rin
authored
refactor(minecraft): restore services to neutral Minecraft semantics (#1950)
## Summary Restores `services/minecraft` to own **only Minecraft semantics**, decoupling the already-merged #1915 runtime contract from the (unmerged) #1916 desktop-relay design — as requested by @shinohara-rin in the #1916 review: > I'd prefer we first restore `services/minecraft` to only own Minecraft semantics, then reintroduce desktop relay/read-aloud through a generic module capability/tool contribution path or a Minecraft adapter. Otherwise `main` now contains half of a cross-PR runtime contract whose other half we are saying should not land as-is. The perception/reflex/brain reliability work from #1915 is untouched. This PR only removes the three desktop-coupling points baked into the bot service. ## Changes - **`airi-bridge.ts` — `handleActionIntent`**: a `spark:command` is high-level guidance from the AIRI server, now attributed to a neutral `'airi'` source. Removed the hardcoded `username = '主人'` / `relayedFrom: 'desktop-airi'` "treat it as if the master typed it in-game" framing. The generic server→bot directive still routes through `signal:chat_message` to trigger a fresh decision cycle, exactly as before — only the identity/provenance is neutralized. Binding a relayed command to the master's in-game identity is desktop-relay policy and will live in the Minecraft adapter. - **`minecraft-context-service.ts`**: stopped emitting the machine-readable `master:` status hint (whose only consumer was the desktop `gaming-minecraft` store) and removed the desktop-coupling NOTICE. The owner identity remains in the human-readable status **text** for the bot's own brain. - **`cognitive/index.ts`**: stopped forwarding the bot's own in-game chat over the `context:update` lane `minecraft:speech` (only meaningful with the #1916 desktop TTS consumer). The bot still ignores its own messages. ## How tested - Updated `minecraft-context-service.test.ts` to assert the neutral behavior (owner identity in status text, **never** as a `master:` hint). 2/2 pass. - `pnpm exec eslint <changed files>` → 0 problems. - The changed files typecheck clean. (Note: this worktree surfaces pre-existing `vec3@0.1.10` vs `vec3@0.2.0` dependency-resolution errors in unrelated files — `gaze.ts`/`runtime.ts`/`patched-goto.ts`/`world.ts`/`map-renderer`/`brain`/`rules` — present on `main` before this change too; not introduced here.) ## Follow-up Desktop relay (`relayToMinecraft`) and read-aloud will be reintroduced through a Minecraft adapter in the renderer (registering tools/prompts into the existing generic stores), so the runtime contract is owned by the Minecraft surface rather than baked into the neutral bot service. The generic stage-ui robustness fixes are already split out in #1949. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Rin <shinohara-rin@users.noreply.github.com>
1 parent 39b68dd commit 027ad74

8 files changed

Lines changed: 210 additions & 57 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
import { AiriBridge } from './airi-bridge'
4+
5+
function createBridgeHarness() {
6+
const handlers = new Map<string, (event: any) => void>()
7+
const client = {
8+
send: vi.fn(),
9+
onEvent: vi.fn((type: string, handler: (event: any) => void) => {
10+
handlers.set(type, handler)
11+
}),
12+
offEvent: vi.fn(),
13+
}
14+
const eventBus = {
15+
emit: vi.fn(),
16+
}
17+
const bridge = new AiriBridge(client as any, eventBus as any)
18+
bridge.init()
19+
20+
return { bridge, eventBus, handlers }
21+
}
22+
23+
describe('airiBridge spark command routing', () => {
24+
it('routes spark commands as AIRI commands instead of chat messages', () => {
25+
const { bridge, eventBus, handlers } = createBridgeHarness()
26+
const commandHandler = handlers.get('spark:command')
27+
28+
expect(commandHandler).toBeDefined()
29+
30+
commandHandler?.({
31+
data: {
32+
commandId: 'spark-1',
33+
intent: 'action',
34+
interrupt: false,
35+
priority: 'normal',
36+
guidance: {
37+
options: [
38+
{
39+
label: 'collect wood',
40+
steps: ['find a tree', 'chop it'],
41+
},
42+
],
43+
},
44+
},
45+
})
46+
47+
expect(eventBus.emit).toHaveBeenCalledWith(expect.objectContaining({
48+
type: 'signal:airi_command',
49+
payload: expect.objectContaining({
50+
type: 'airi_command',
51+
description: 'Directive from AIRI: "collect wood"',
52+
sourceId: 'airi',
53+
metadata: expect.objectContaining({
54+
message: 'collect wood',
55+
sparkCommandId: 'spark-1',
56+
sparkIntent: 'action',
57+
}),
58+
}),
59+
}))
60+
expect(eventBus.emit).not.toHaveBeenCalledWith(expect.objectContaining({
61+
type: 'signal:chat_message',
62+
}))
63+
64+
bridge.destroy()
65+
})
66+
})

services/minecraft/src/airi/airi-bridge.ts

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,15 @@ export class AiriBridge {
4444
},
4545
} as Parameters<typeof this.client.send>[0])
4646

47-
// A spark:command IS a command. The user requires that a desktop-relayed command carry the
48-
// EXACT same weight as the master typing in the in-game chat — i.e. it must trigger a fresh
49-
// decision (Conscious) cycle, never be silently filed into history. So we always route through
50-
// handleActionIntent (→ signal:chat_message → enqueueEvent → decision cycle).
47+
// A spark:command is high-level guidance from the AIRI server. It must carry enough weight to
48+
// trigger a fresh decision (Conscious) cycle, never be silently filed into history — so we
49+
// always route it through handleActionIntent (→ signal:airi_command → enqueueEvent → decision cycle).
5150
//
52-
// We intentionally no longer special-case `intent === 'context'`: that branch used to emit
51+
// We intentionally do not special-case `intent === 'context'`: that branch used to emit
5352
// signal:airi_context which Brain pushes to conversationHistory WITHOUT waking the loop, so a
54-
// desktop LLM that mislabels its intent as "context" would have its command silently dropped
55-
// from action. True passive context still has its own dedicated channel — `context:update`
56-
// (see contextUpdateHandler) — which remains history-only and is unaffected by this change.
53+
// command that mislabels its intent as "context" would be silently dropped from action. True
54+
// passive context still has its own dedicated channel — `context:update` (see
55+
// contextUpdateHandler) — which remains history-only and is unaffected by this routing.
5756
this.handleActionIntent(cmd)
5857
}
5958

@@ -180,43 +179,41 @@ export class AiriBridge {
180179
}
181180

182181
private handleActionIntent(cmd: SparkCommandData): void {
183-
// Treat a desktop-AIRI spark:command as if the master typed it in the in-game chat:
184-
// route through the SAME `signal:chat_message` path so brain handles it identically to
185-
// a real player chat (resetNoActionFollowupBudget('player_chat'), normal Conscious wake
186-
// up, no special "another agent" framing). User intent: "the desktop AIRI is just an
187-
// extension of me — when she relays a command, the in-game bot should feel it as me."
182+
// A spark:command is high-level guidance from the AIRI server. Route it through the explicit
183+
// `airi_command` signal so the brain runs a fresh decision cycle
184+
// (resetNoActionFollowupBudget('airi_command'), normal Conscious wake-up) instead of silently
185+
// filing it into history. The directive is attributed to the AIRI server as a neutral source,
186+
// not to any specific in-game player. Binding a relayed command to the master's in-game identity
187+
// is desktop-relay policy and lives in the desktop Minecraft adapter, not in this bot service.
188188
const firstOption = cmd.guidance?.options?.[0]
189189
const label = firstOption?.label?.trim()
190190
const steps = firstOption?.steps ?? []
191-
// Prefer the short label (closest to what the user actually said). Fall back to joined
192-
// steps so brain still has detail when label is missing.
191+
// Prefer the short label (closest to the original instruction). Fall back to joined steps so the
192+
// brain still has detail when label is missing.
193193
const message = label && label.length > 0
194194
? label
195195
: (steps.length > 0 ? steps.join(' / ') : `${cmd.intent} command received`)
196196

197-
const username = '主人'
197+
const sourceId = 'airi'
198198

199-
this.logger.log('Relaying AIRI spark:command as in-game chat from master', {
199+
this.logger.log('Routing spark:command as an AIRI directive', {
200200
commandId: cmd.commandId,
201201
message,
202202
})
203203

204204
this.eventBus.emit({
205-
type: 'signal:chat_message',
205+
type: 'signal:airi_command',
206206
payload: Object.freeze({
207-
type: 'chat_message' as const,
208-
description: `Chat from ${username}: "${message}"`,
209-
sourceId: username,
207+
type: 'airi_command' as const,
208+
description: `Directive from AIRI: "${message}"`,
209+
sourceId,
210210
confidence: 1.0,
211211
timestamp: Date.now(),
212212
metadata: {
213-
username,
214213
message,
215-
// Keep the spark provenance for debugging / future special-casing, but the brain
216-
// doesn't need to know — it just sees a chat_message from the master.
214+
// Keep the spark provenance for debugging; the brain sees a typed AIRI directive.
217215
sparkCommandId: cmd.commandId,
218216
sparkIntent: cmd.intent,
219-
relayedFrom: 'desktop-airi',
220217
},
221218
}),
222219
source: { component: 'airi', id: 'bridge' },

services/minecraft/src/airi/minecraft-context-service.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,22 @@ function makeService(masterUsername?: string) {
3030
return { service, captured }
3131
}
3232

33-
describe('minecraftContextService master propagation', () => {
34-
it('carries the master username to the desktop via status hints and text', () => {
33+
describe('minecraftContextService master identity', () => {
34+
it('surfaces the configured master username in the status text only', () => {
3535
const { service, captured } = makeService('dssadg')
3636
service.bindBot(fakeBot())
3737
const update = captured[0]
3838
expect(update.lane).toBe('minecraft:status')
39-
expect(update.hints).toContain('master:dssadg')
4039
expect(update.text).toContain('Master (your owner) in-game username: dssadg')
40+
// The owner identity rides only in the human-readable status text (for the bot's own brain). It
41+
// must NOT leak as a machine-readable `master:` hint — that was a desktop-store coupling point,
42+
// removed in the services/minecraft neutral restore. Desktop "主人" binding is reintroduced via
43+
// the Minecraft adapter, not baked into the bot service.
44+
expect(update.hints.some((hint: string) => hint.startsWith('master:'))).toBe(false)
4145
service.destroy()
4246
})
4347

44-
it('omits the master hint when no master username is configured', () => {
48+
it('omits the master line when no master username is configured', () => {
4549
const { service, captured } = makeService(undefined)
4650
service.bindBot(fakeBot())
4751
const update = captured[0]

services/minecraft/src/airi/minecraft-context-service.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,6 @@ const STATUS_CONTEXT_ID = 'minecraft:status'
2222
const STATUS_LANE = 'minecraft:status'
2323
const STATUS_REFRESH_INTERVAL_MS = 5_000
2424

25-
// NOTICE: hint prefix carrying the master's in-game username to the desktop. The desktop skips the
26-
// minecraft:status text for its runtime context, so the binding rides on `hints` instead, where the
27-
// desktop store extracts it (gaming-minecraft.ts). Keep this literal in sync with the desktop side.
28-
const MASTER_HINT_PREFIX = 'master:'
29-
3025
function toPositionString(bot: MineflayerWithAgents) {
3126
const position = bot.bot.entity?.position
3227
return position
@@ -138,7 +133,6 @@ export class MinecraftContextService {
138133
hints: [
139134
'status',
140135
snapshot.botUsername,
141-
...(snapshot.masterUsername ? [`${MASTER_HINT_PREFIX}${snapshot.masterUsername}`] : []),
142136
],
143137
strategy: ContextUpdateStrategy.ReplaceSelf,
144138
}

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,38 @@ function createPerceptionEvent() {
8080
} as any
8181
}
8282

83+
function createAiriCommandEvent() {
84+
return {
85+
type: 'perception',
86+
payload: {
87+
type: 'airi_command',
88+
description: 'Directive from AIRI: "continue"',
89+
sourceId: 'airi',
90+
confidence: 1,
91+
timestamp: Date.now(),
92+
metadata: { message: 'continue', sparkCommandId: 'spark-1', sparkIntent: 'action' },
93+
},
94+
source: { type: 'airi', id: 'airi' },
95+
timestamp: Date.now(),
96+
} as any
97+
}
98+
99+
function createNonResumingPerceptionEvent() {
100+
return {
101+
type: 'perception',
102+
payload: {
103+
type: 'saliency_high',
104+
description: 'Distant noise',
105+
sourceId: 'world',
106+
confidence: 1,
107+
timestamp: Date.now(),
108+
metadata: { action: 'noise' },
109+
},
110+
source: { type: 'minecraft', id: 'world' },
111+
timestamp: Date.now(),
112+
} as any
113+
}
114+
83115
function createAsyncControlAction(name: string = 'goToPlayer') {
84116
return {
85117
name,
@@ -262,6 +294,51 @@ inv;
262294
})
263295
})
264296

297+
it('clears giveUp and proceeds when player chat arrives', async () => {
298+
const deps: any = createDeps('await skip()')
299+
const brain: any = new Brain(deps)
300+
brain.givenUp = true
301+
brain.giveUpReason = 'stuck'
302+
303+
await brain.processEvent({} as any, createPerceptionEvent())
304+
305+
expect(brain.givenUp).toBe(false)
306+
expect(brain.giveUpReason).toBeUndefined()
307+
expect(deps.llmAgent.callLLM).toHaveBeenCalledTimes(1)
308+
})
309+
310+
it('clears giveUp and proceeds when an AIRI command arrives', async () => {
311+
const deps: any = createDeps('await skip()')
312+
const brain: any = new Brain(deps)
313+
brain.givenUp = true
314+
brain.giveUpReason = 'stuck'
315+
brain.setNoActionFollowupBudget(0)
316+
317+
await brain.processEvent({} as any, createAiriCommandEvent())
318+
319+
expect(brain.givenUp).toBe(false)
320+
expect(brain.giveUpReason).toBeUndefined()
321+
expect(brain.getNoActionBudgetState()).toEqual({
322+
remaining: 3,
323+
default: 3,
324+
max: 8,
325+
})
326+
expect(deps.llmAgent.callLLM).toHaveBeenCalledTimes(1)
327+
})
328+
329+
it('keeps suppressing non-chat and non-AIRI perceptions while giveUp is active', async () => {
330+
const deps: any = createDeps('await skip()')
331+
const brain: any = new Brain(deps)
332+
brain.givenUp = true
333+
brain.giveUpReason = 'stuck'
334+
335+
await brain.processEvent({} as any, createNonResumingPerceptionEvent())
336+
337+
expect(brain.givenUp).toBe(true)
338+
expect(brain.giveUpReason).toBe('stuck')
339+
expect(deps.llmAgent.callLLM).not.toHaveBeenCalled()
340+
})
341+
265342
it('does not queue follow-up when script uses skip()', async () => {
266343
const brain: any = new Brain(createDeps('await skip()'))
267344
const enqueueSpy = vi.fn(async () => undefined)
@@ -455,6 +532,23 @@ describe('brain queue coalescing', () => {
455532
expect((brain.queue[0].event.payload as any).type).toBe('chat_message')
456533
})
457534

535+
it('promotes AIRI commands ahead of queued ordinary perceptions', () => {
536+
const brain: any = new Brain(createDeps('await skip()'))
537+
538+
brain.queue = [
539+
{ event: createNonResumingPerceptionEvent(), resolve: vi.fn(), reject: vi.fn() },
540+
{ event: createFeedbackEvent(), resolve: vi.fn(), reject: vi.fn() },
541+
{ event: createAiriCommandEvent(), resolve: vi.fn(), reject: vi.fn() },
542+
]
543+
544+
brain.coalesceQueue()
545+
546+
expect(brain.queue[0].event.type).toBe('perception')
547+
expect((brain.queue[0].event.payload as any).type).toBe('airi_command')
548+
expect((brain.queue[1].event.payload as any).type).toBe('saliency_high')
549+
expect(brain.queue[2].event.type).toBe('feedback')
550+
})
551+
458552
it('drops no-action follow-ups when player chat is waiting', () => {
459553
const brain: any = new Brain(createDeps('await skip()'))
460554

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

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,9 @@ const NO_ACTION_BUDGET_ALERT_SOURCE_ID = 'brain:no_action_budget'
220220

221221
/**
222222
* Priority tiers for event scheduling (lower = higher priority).
223-
* Player chat always takes precedence over stale system feedback.
223+
* Player chat and AIRI commands always take precedence over stale system feedback.
224224
*/
225-
const EVENT_PRIORITY_PLAYER_CHAT = 0
225+
const EVENT_PRIORITY_URGENT_PERCEPTION = 0
226226
const EVENT_PRIORITY_PERCEPTION = 1
227227
const EVENT_PRIORITY_FEEDBACK = 2
228228
const EVENT_PRIORITY_NO_ACTION_FOLLOWUP = 3
@@ -265,8 +265,8 @@ function augmentDecisionError(message: string): string {
265265
function getEventPriority(event: BotEvent): number {
266266
if (event.type === 'perception') {
267267
const signal = event.payload as PerceptionSignal
268-
if (signal.type === 'chat_message')
269-
return EVENT_PRIORITY_PLAYER_CHAT
268+
if (signal.type === 'chat_message' || signal.type === 'airi_command')
269+
return EVENT_PRIORITY_URGENT_PERCEPTION
270270
return EVENT_PRIORITY_PERCEPTION
271271
}
272272
if (event.source.type === 'system' && event.source.id === NO_ACTION_FOLLOWUP_SOURCE_ID)
@@ -1578,7 +1578,7 @@ export class Brain {
15781578
}
15791579

15801580
/**
1581-
* Coalesce the event queue: promote high-priority events (player chat)
1581+
* Coalesce the event queue: promote high-priority events (player chat, AIRI commands)
15821582
* ahead of stale low-priority events (feedback, no-action follow-ups),
15831583
* and drop redundant stale follow-ups when a higher-priority event exists.
15841584
*/
@@ -1592,11 +1592,11 @@ export class Brain {
15921592
if (!hasHighPriority)
15931593
return
15941594

1595-
// Drop redundant no-action follow-ups when a player chat is waiting
1596-
const hasPlayerChat = this.queue.some(
1597-
item => getEventPriority(item.event) === EVENT_PRIORITY_PLAYER_CHAT,
1595+
// Drop redundant no-action follow-ups when an urgent perception is waiting
1596+
const hasUrgentPerception = this.queue.some(
1597+
item => getEventPriority(item.event) === EVENT_PRIORITY_URGENT_PERCEPTION,
15981598
)
1599-
if (hasPlayerChat) {
1599+
if (hasUrgentPerception) {
16001600
const before = this.queue.length
16011601
const dropped: QueuedEvent[] = []
16021602
this.queue = this.queue.filter((item) => {
@@ -1618,12 +1618,12 @@ export class Brain {
16181618
sourceType: 'system',
16191619
sourceId: 'brain:coalesce',
16201620
tags: ['scheduler', 'coalesce', 'drop_followups'],
1621-
text: `Coalesced queue: dropped ${before - this.queue.length} stale no-action follow-ups (player chat waiting)`,
1621+
text: `Coalesced queue: dropped ${before - this.queue.length} stale no-action follow-ups (urgent perception waiting)`,
16221622
})
16231623
}
16241624
}
16251625

1626-
// Stable-sort by priority so player chat events are processed first
1626+
// Stable-sort by priority so urgent perception events are processed first
16271627
this.queue.sort((a, b) => getEventPriority(a.event) - getEventPriority(b.event))
16281628
}
16291629

@@ -2305,7 +2305,7 @@ export class Brain {
23052305
return true
23062306

23072307
const signal = event.payload as PerceptionSignal
2308-
return signal.type !== 'chat_message'
2308+
return !this.canResumeFromGiveUp(signal)
23092309
}
23102310

23112311
private resumeFromGiveUpIfNeeded(event: BotEvent): void {
@@ -2316,10 +2316,14 @@ export class Brain {
23162316
return
23172317

23182318
const signal = event.payload as PerceptionSignal
2319-
if (signal.type !== 'chat_message')
2319+
if (!this.canResumeFromGiveUp(signal))
23202320
return
23212321

23222322
this.givenUp = false
23232323
this.giveUpReason = undefined
23242324
}
2325+
2326+
private canResumeFromGiveUp(signal: PerceptionSignal): boolean {
2327+
return signal.type === 'chat_message' || signal.type === 'airi_command'
2328+
}
23252329
}

0 commit comments

Comments
 (0)