feat(minecraft): expose LLM log to runtime for introspection
Add LlmLogEntry interface with id/turnId/kind/timestamp/eventType/sourceType/sourceId/tags/text/metadata fields, implement createLlmLogRuntime helper exposing query API with chainable filters (byTurn/byKind/byEventType/byTag/last/list/count/clear), track llmLogEntries array with appendLlmLog method logging turn_input/llm_attempt/planner_result/planner_error/feedback/scheduler events, inject llmLog/currentInput globals
This commit is contained in:
@@ -9,12 +9,14 @@ import type { ReflexManager } from '../reflex/reflex-manager'
|
|||||||
import type { BotEvent, MineflayerWithAgents } from '../types'
|
import type { BotEvent, MineflayerWithAgents } from '../types'
|
||||||
import type { PlannerGlobalDescriptor } from './js-planner'
|
import type { PlannerGlobalDescriptor } from './js-planner'
|
||||||
import type { LLMAgent } from './llm-agent'
|
import type { LLMAgent } from './llm-agent'
|
||||||
|
import type { LlmLogEntry, LlmLogEntryKind } from './llm-log'
|
||||||
import type { CancellationToken } from './task-state'
|
import type { CancellationToken } from './task-state'
|
||||||
|
|
||||||
import { config } from '../../composables/config'
|
import { config } from '../../composables/config'
|
||||||
import { DebugService } from '../../debug'
|
import { DebugService } from '../../debug'
|
||||||
import { buildConsciousContextView } from './context-view'
|
import { buildConsciousContextView } from './context-view'
|
||||||
import { JavaScriptPlanner } from './js-planner'
|
import { JavaScriptPlanner } from './js-planner'
|
||||||
|
import { createLlmLogRuntime } from './llm-log'
|
||||||
import {
|
import {
|
||||||
isLikelyAuthOrBadArgError,
|
isLikelyAuthOrBadArgError,
|
||||||
isRateLimitError,
|
isRateLimitError,
|
||||||
@@ -73,10 +75,48 @@ interface LlmInputSnapshot {
|
|||||||
attempt: number
|
attempt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RuntimeInputEnvelope {
|
||||||
|
id: number
|
||||||
|
turnId: number
|
||||||
|
timestamp: number
|
||||||
|
event: {
|
||||||
|
type: string
|
||||||
|
sourceType: string
|
||||||
|
sourceId: string
|
||||||
|
payload: unknown
|
||||||
|
}
|
||||||
|
contextView: string
|
||||||
|
userMessage: string
|
||||||
|
systemPrompt: {
|
||||||
|
preview: string
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
llm?: {
|
||||||
|
attempt: number
|
||||||
|
model: string
|
||||||
|
usage?: {
|
||||||
|
prompt_tokens?: number
|
||||||
|
completion_tokens?: number
|
||||||
|
total_tokens?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function truncateForPrompt(value: string, maxLength = 220): string {
|
function truncateForPrompt(value: string, maxLength = 220): string {
|
||||||
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}...`
|
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}...`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringifyForLog(value: unknown): string {
|
||||||
|
if (typeof value === 'string')
|
||||||
|
return value
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const NO_ACTION_FOLLOWUP_SOURCE_ID = 'brain:no_action_followup'
|
const NO_ACTION_FOLLOWUP_SOURCE_ID = 'brain:no_action_followup'
|
||||||
|
|
||||||
export class Brain {
|
export class Brain {
|
||||||
@@ -97,6 +137,11 @@ export class Brain {
|
|||||||
private conversationHistory: Message[] = []
|
private conversationHistory: Message[] = []
|
||||||
private lastLlmInputSnapshot: LlmInputSnapshot | null = null
|
private lastLlmInputSnapshot: LlmInputSnapshot | null = null
|
||||||
private runtimeMineflayer: MineflayerWithAgents | null = null
|
private runtimeMineflayer: MineflayerWithAgents | null = null
|
||||||
|
private readonly llmLogEntries: LlmLogEntry[] = []
|
||||||
|
private llmLogIdCounter = 0
|
||||||
|
private turnCounter = 0
|
||||||
|
private currentInputEnvelope: RuntimeInputEnvelope | null = null
|
||||||
|
private readonly llmLogRuntime = createLlmLogRuntime(() => this.llmLogEntries)
|
||||||
|
|
||||||
constructor(private readonly deps: BrainDeps) {
|
constructor(private readonly deps: BrainDeps) {
|
||||||
this.debugService = DebugService.getInstance()
|
this.debugService = DebugService.getInstance()
|
||||||
@@ -120,6 +165,19 @@ export class Brain {
|
|||||||
// Action Feedback Handler
|
// Action Feedback Handler
|
||||||
this.deps.taskExecutor.on('action:completed', async ({ action, result }) => {
|
this.deps.taskExecutor.on('action:completed', async ({ action, result }) => {
|
||||||
this.deps.logger.log('INFO', `Brain: Action completed: ${action.tool}`)
|
this.deps.logger.log('INFO', `Brain: Action completed: ${action.tool}`)
|
||||||
|
this.appendLlmLog({
|
||||||
|
turnId: this.turnCounter,
|
||||||
|
kind: 'feedback',
|
||||||
|
eventType: 'feedback',
|
||||||
|
sourceType: 'system',
|
||||||
|
sourceId: 'executor',
|
||||||
|
tags: ['feedback', 'success', action.tool],
|
||||||
|
text: `Action completed: ${action.tool}`,
|
||||||
|
metadata: {
|
||||||
|
params: action.params,
|
||||||
|
result: stringifyForLog(result),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (action.tool === 'chat' && action.params?.feedback !== true) {
|
if (action.tool === 'chat' && action.params?.feedback !== true) {
|
||||||
return
|
return
|
||||||
@@ -142,6 +200,18 @@ export class Brain {
|
|||||||
|
|
||||||
this.deps.taskExecutor.on('action:failed', async ({ action, error }) => {
|
this.deps.taskExecutor.on('action:failed', async ({ action, error }) => {
|
||||||
this.deps.logger.withError(error).warn(`Brain: Action failed: ${action.tool}`)
|
this.deps.logger.withError(error).warn(`Brain: Action failed: ${action.tool}`)
|
||||||
|
this.appendLlmLog({
|
||||||
|
turnId: this.turnCounter,
|
||||||
|
kind: 'feedback',
|
||||||
|
eventType: 'feedback',
|
||||||
|
sourceType: 'system',
|
||||||
|
sourceId: 'executor',
|
||||||
|
tags: ['feedback', 'error', action.tool],
|
||||||
|
text: `Action failed: ${action.tool}: ${error?.message || String(error)}`,
|
||||||
|
metadata: {
|
||||||
|
params: action.params,
|
||||||
|
},
|
||||||
|
})
|
||||||
this.enqueueEvent(bot, {
|
this.enqueueEvent(bot, {
|
||||||
type: 'feedback',
|
type: 'feedback',
|
||||||
payload: { status: 'failure', action, error: error.message || error },
|
payload: { status: 'failure', action, error: error.message || error },
|
||||||
@@ -160,20 +230,15 @@ export class Brain {
|
|||||||
|
|
||||||
public getReplState(): { variables: PlannerGlobalDescriptor[], updatedAt: number } {
|
public getReplState(): { variables: PlannerGlobalDescriptor[], updatedAt: number } {
|
||||||
const snapshot = this.deps.reflexManager.getContextSnapshot()
|
const snapshot = this.deps.reflexManager.getContextSnapshot()
|
||||||
|
const replEvent: BotEvent = {
|
||||||
|
type: 'system_alert',
|
||||||
|
payload: { source: 'debug-repl-state' },
|
||||||
|
source: { type: 'system', id: 'debug-repl' },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
const variables = this.planner.describeGlobals(
|
const variables = this.planner.describeGlobals(
|
||||||
this.deps.taskExecutor.getAvailableActions(),
|
this.deps.taskExecutor.getAvailableActions(),
|
||||||
{
|
this.createRuntimeGlobals(replEvent, snapshot as unknown as Record<string, unknown>),
|
||||||
event: {
|
|
||||||
type: 'system_alert',
|
|
||||||
payload: { source: 'debug-repl-state' },
|
|
||||||
source: { type: 'system', id: 'debug-repl' },
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
snapshot: snapshot as unknown as Record<string, unknown>,
|
|
||||||
mineflayer: this.runtimeMineflayer,
|
|
||||||
bot: this.runtimeMineflayer?.bot,
|
|
||||||
llmInput: this.lastLlmInputSnapshot,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -208,18 +273,12 @@ export class Brain {
|
|||||||
const runResult = await this.planner.evaluate(
|
const runResult = await this.planner.evaluate(
|
||||||
codeToEvaluate,
|
codeToEvaluate,
|
||||||
this.deps.taskExecutor.getAvailableActions(),
|
this.deps.taskExecutor.getAvailableActions(),
|
||||||
{
|
this.createRuntimeGlobals({
|
||||||
event: {
|
type: 'system_alert',
|
||||||
type: 'system_alert',
|
payload: { source: 'debug-repl' },
|
||||||
payload: { source: 'debug-repl' },
|
source: { type: 'system', id: 'debug-repl' },
|
||||||
source: { type: 'system', id: 'debug-repl' },
|
timestamp: Date.now(),
|
||||||
timestamp: Date.now(),
|
}, snapshot as unknown as Record<string, unknown>),
|
||||||
},
|
|
||||||
snapshot: snapshot as unknown as Record<string, unknown>,
|
|
||||||
mineflayer: this.runtimeMineflayer,
|
|
||||||
bot: this.runtimeMineflayer?.bot,
|
|
||||||
llmInput: this.lastLlmInputSnapshot,
|
|
||||||
},
|
|
||||||
async (action: ActionInstruction) => {
|
async (action: ActionInstruction) => {
|
||||||
const actionDef = actionDefs.get(action.tool)
|
const actionDef = actionDefs.get(action.tool)
|
||||||
if (actionDef?.followControl === 'detach')
|
if (actionDef?.followControl === 'detach')
|
||||||
@@ -282,15 +341,70 @@ export class Brain {
|
|||||||
return JSON.parse(JSON.stringify(messages)) as Message[]
|
return JSON.parse(JSON.stringify(messages)) as Message[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createRuntimeGlobals(
|
||||||
|
event: BotEvent,
|
||||||
|
snapshot: Record<string, unknown>,
|
||||||
|
mineflayerOverride?: MineflayerWithAgents | null,
|
||||||
|
) {
|
||||||
|
const mineflayer = mineflayerOverride ?? this.runtimeMineflayer
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
snapshot,
|
||||||
|
mineflayer,
|
||||||
|
bot: mineflayer?.bot,
|
||||||
|
llmInput: this.lastLlmInputSnapshot,
|
||||||
|
currentInput: this.currentInputEnvelope,
|
||||||
|
llmLog: this.llmLogRuntime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendLlmLog(entry: {
|
||||||
|
turnId: number
|
||||||
|
kind: LlmLogEntryKind
|
||||||
|
eventType: string
|
||||||
|
sourceType: string
|
||||||
|
sourceId: string
|
||||||
|
tags?: string[]
|
||||||
|
text: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}): void {
|
||||||
|
const normalized: LlmLogEntry = {
|
||||||
|
id: ++this.llmLogIdCounter,
|
||||||
|
turnId: entry.turnId,
|
||||||
|
kind: entry.kind,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
eventType: entry.eventType,
|
||||||
|
sourceType: entry.sourceType,
|
||||||
|
sourceId: entry.sourceId,
|
||||||
|
tags: entry.tags ?? [],
|
||||||
|
text: entry.text,
|
||||||
|
metadata: entry.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.llmLogEntries.push(normalized)
|
||||||
|
if (this.llmLogEntries.length > 1000) {
|
||||||
|
this.llmLogEntries.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private queueNoActionFollowup(
|
private queueNoActionFollowup(
|
||||||
bot: MineflayerWithAgents,
|
bot: MineflayerWithAgents,
|
||||||
triggeringEvent: BotEvent,
|
triggeringEvent: BotEvent,
|
||||||
|
turnId: number,
|
||||||
returnValue: string | undefined,
|
returnValue: string | undefined,
|
||||||
logs: string[],
|
logs: string[],
|
||||||
): void {
|
): void {
|
||||||
if (triggeringEvent.source.type === 'system' && triggeringEvent.source.id === NO_ACTION_FOLLOWUP_SOURCE_ID) {
|
if (triggeringEvent.source.type === 'system' && triggeringEvent.source.id === NO_ACTION_FOLLOWUP_SOURCE_ID) {
|
||||||
this.deps.logger.log('INFO', 'Brain: Suppressed no-action follow-up (already in follow-up chain)')
|
this.deps.logger.log('INFO', 'Brain: Suppressed no-action follow-up (already in follow-up chain)')
|
||||||
this.debugService.log('DEBUG', 'No-action follow-up suppressed (already follow-up source)')
|
this.appendLlmLog({
|
||||||
|
turnId,
|
||||||
|
kind: 'scheduler',
|
||||||
|
eventType: triggeringEvent.type,
|
||||||
|
sourceType: triggeringEvent.source.type,
|
||||||
|
sourceId: triggeringEvent.source.id,
|
||||||
|
tags: ['scheduler', 'no_action', 'suppressed'],
|
||||||
|
text: 'No-action follow-up suppressed: already follow-up source',
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +419,18 @@ export class Brain {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.appendLlmLog({
|
||||||
|
turnId,
|
||||||
|
kind: 'scheduler',
|
||||||
|
eventType: triggeringEvent.type,
|
||||||
|
sourceType: triggeringEvent.source.type,
|
||||||
|
sourceId: triggeringEvent.source.id,
|
||||||
|
tags: ['scheduler', 'no_action'],
|
||||||
|
text: 'Scheduled one-hop no-action follow-up',
|
||||||
|
metadata: {
|
||||||
|
returnValue: returnValue ?? 'undefined',
|
||||||
|
},
|
||||||
|
})
|
||||||
this.debugService.log('DEBUG', 'Scheduling one-hop no-action follow-up turn')
|
this.debugService.log('DEBUG', 'Scheduling one-hop no-action follow-up turn')
|
||||||
void this.enqueueEvent(bot, followupEvent).catch(err =>
|
void this.enqueueEvent(bot, followupEvent).catch(err =>
|
||||||
this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action follow-up'),
|
this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action follow-up'),
|
||||||
@@ -378,6 +504,36 @@ export class Brain {
|
|||||||
|
|
||||||
// 2. Prepare System Prompt (static)
|
// 2. Prepare System Prompt (static)
|
||||||
const systemPrompt = generateBrainSystemPrompt(this.deps.taskExecutor.getAvailableActions())
|
const systemPrompt = generateBrainSystemPrompt(this.deps.taskExecutor.getAvailableActions())
|
||||||
|
const turnId = ++this.turnCounter
|
||||||
|
this.currentInputEnvelope = {
|
||||||
|
id: turnId,
|
||||||
|
turnId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
event: {
|
||||||
|
type: event.type,
|
||||||
|
sourceType: event.source.type,
|
||||||
|
sourceId: event.source.id,
|
||||||
|
payload: event.payload,
|
||||||
|
},
|
||||||
|
contextView,
|
||||||
|
userMessage,
|
||||||
|
systemPrompt: {
|
||||||
|
preview: truncateForPrompt(systemPrompt, 240),
|
||||||
|
length: systemPrompt.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.appendLlmLog({
|
||||||
|
turnId,
|
||||||
|
kind: 'turn_input',
|
||||||
|
eventType: event.type,
|
||||||
|
sourceType: event.source.type,
|
||||||
|
sourceId: event.source.id,
|
||||||
|
tags: ['input', event.type],
|
||||||
|
text: truncateForPrompt(userMessage, 600),
|
||||||
|
metadata: {
|
||||||
|
queueLength: this.queue.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// 3. Call LLM with retry logic
|
// 3. Call LLM with retry logic
|
||||||
const maxAttempts = 3
|
const maxAttempts = 3
|
||||||
@@ -400,6 +556,24 @@ export class Brain {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
attempt,
|
attempt,
|
||||||
}
|
}
|
||||||
|
this.currentInputEnvelope.llm = {
|
||||||
|
attempt,
|
||||||
|
model: config.openai.model,
|
||||||
|
}
|
||||||
|
this.appendLlmLog({
|
||||||
|
turnId,
|
||||||
|
kind: 'llm_attempt',
|
||||||
|
eventType: event.type,
|
||||||
|
sourceType: event.source.type,
|
||||||
|
sourceId: event.source.id,
|
||||||
|
tags: ['llm', 'attempt'],
|
||||||
|
text: `LLM attempt ${attempt}/${maxAttempts}`,
|
||||||
|
metadata: {
|
||||||
|
attempt,
|
||||||
|
maxAttempts,
|
||||||
|
messageCount: messages.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const traceStart = Date.now()
|
const traceStart = Date.now()
|
||||||
|
|
||||||
@@ -426,6 +600,25 @@ export class Brain {
|
|||||||
model: config.openai.model,
|
model: config.openai.model,
|
||||||
duration: Date.now() - traceStart,
|
duration: Date.now() - traceStart,
|
||||||
})
|
})
|
||||||
|
this.currentInputEnvelope.llm = {
|
||||||
|
attempt,
|
||||||
|
model: config.openai.model,
|
||||||
|
usage: llmResult.usage,
|
||||||
|
}
|
||||||
|
this.appendLlmLog({
|
||||||
|
turnId,
|
||||||
|
kind: 'llm_attempt',
|
||||||
|
eventType: event.type,
|
||||||
|
sourceType: event.source.type,
|
||||||
|
sourceId: event.source.id,
|
||||||
|
tags: ['llm', 'response'],
|
||||||
|
text: truncateForPrompt(content, 400),
|
||||||
|
metadata: {
|
||||||
|
attempt,
|
||||||
|
usage: llmResult.usage,
|
||||||
|
reasoningSize: reasoning?.length ?? 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
this.debugService.emitBrainState({
|
this.debugService.emitBrainState({
|
||||||
status: 'processing',
|
status: 'processing',
|
||||||
@@ -455,6 +648,15 @@ export class Brain {
|
|||||||
// 4. Parse & Execute
|
// 4. Parse & Execute
|
||||||
if (!result) {
|
if (!result) {
|
||||||
this.deps.logger.warn('Brain: No response after all retries')
|
this.deps.logger.warn('Brain: No response after all retries')
|
||||||
|
this.appendLlmLog({
|
||||||
|
turnId,
|
||||||
|
kind: 'planner_error',
|
||||||
|
eventType: event.type,
|
||||||
|
sourceType: event.source.type,
|
||||||
|
sourceId: event.source.id,
|
||||||
|
tags: ['planner', 'error', 'empty_response'],
|
||||||
|
text: 'No LLM response after retries',
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,13 +681,7 @@ export class Brain {
|
|||||||
const runResult = await this.planner.evaluate(
|
const runResult = await this.planner.evaluate(
|
||||||
codeToEvaluate,
|
codeToEvaluate,
|
||||||
this.deps.taskExecutor.getAvailableActions(),
|
this.deps.taskExecutor.getAvailableActions(),
|
||||||
{
|
this.createRuntimeGlobals(event, snapshot as unknown as Record<string, unknown>, bot),
|
||||||
event,
|
|
||||||
snapshot: snapshot as unknown as Record<string, unknown>,
|
|
||||||
mineflayer: bot,
|
|
||||||
bot: bot.bot,
|
|
||||||
llmInput: this.lastLlmInputSnapshot,
|
|
||||||
},
|
|
||||||
async (action: ActionInstruction) => {
|
async (action: ActionInstruction) => {
|
||||||
if (action.tool === 'chat' && !this.shouldAllowChatForEvent(event, snapshot.self.health)) {
|
if (action.tool === 'chat' && !this.shouldAllowChatForEvent(event, snapshot.self.health)) {
|
||||||
return 'Chat suppressed: no direct user prompt for chat this turn'
|
return 'Chat suppressed: no direct user prompt for chat this turn'
|
||||||
@@ -518,6 +714,31 @@ export class Brain {
|
|||||||
logs: runResult.logs.slice(-3),
|
logs: runResult.logs.slice(-3),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}
|
}
|
||||||
|
this.appendLlmLog({
|
||||||
|
turnId,
|
||||||
|
kind: 'planner_result',
|
||||||
|
eventType: event.type,
|
||||||
|
sourceType: event.source.type,
|
||||||
|
sourceId: event.source.id,
|
||||||
|
tags: [
|
||||||
|
'planner',
|
||||||
|
runResult.actions.length === 0 ? 'no_actions' : 'actions',
|
||||||
|
runResult.actions.some(item => !item.ok) ? 'error' : 'ok',
|
||||||
|
],
|
||||||
|
text: `actions=${runResult.actions.length} return=${runResult.returnValue ?? 'undefined'}`,
|
||||||
|
metadata: {
|
||||||
|
returnValue: runResult.returnValue,
|
||||||
|
actionCount: runResult.actions.length,
|
||||||
|
okCount: runResult.actions.filter(item => item.ok).length,
|
||||||
|
errorCount: runResult.actions.filter(item => !item.ok).length,
|
||||||
|
actions: runResult.actions.map(item => ({
|
||||||
|
tool: item.action.tool,
|
||||||
|
ok: item.ok,
|
||||||
|
error: item.error,
|
||||||
|
})),
|
||||||
|
logs: runResult.logs.slice(-5),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (runResult.actions.length === 0 || runResult.actions.every(item => item.action.tool === 'skip')) {
|
if (runResult.actions.length === 0 || runResult.actions.every(item => item.action.tool === 'skip')) {
|
||||||
this.debugService.emit('debug:repl_result', {
|
this.debugService.emit('debug:repl_result', {
|
||||||
@@ -530,7 +751,7 @@ export class Brain {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
if (runResult.actions.length === 0) {
|
if (runResult.actions.length === 0) {
|
||||||
this.queueNoActionFollowup(bot, event, runResult.returnValue, runResult.logs)
|
this.queueNoActionFollowup(bot, event, turnId, runResult.returnValue, runResult.logs)
|
||||||
}
|
}
|
||||||
this.deps.logger.log('INFO', 'Brain: Skipping turn (observing)')
|
this.deps.logger.log('INFO', 'Brain: Skipping turn (observing)')
|
||||||
return
|
return
|
||||||
@@ -559,6 +780,18 @@ export class Brain {
|
|||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
this.deps.logger.withError(err).error('Brain: Failed to execute decision')
|
this.deps.logger.withError(err).error('Brain: Failed to execute decision')
|
||||||
|
this.appendLlmLog({
|
||||||
|
turnId,
|
||||||
|
kind: 'planner_error',
|
||||||
|
eventType: event.type,
|
||||||
|
sourceType: event.source.type,
|
||||||
|
sourceId: event.source.id,
|
||||||
|
tags: ['planner', 'error'],
|
||||||
|
text: truncateForPrompt(toErrorMessage(err), 360),
|
||||||
|
metadata: {
|
||||||
|
code: result,
|
||||||
|
},
|
||||||
|
})
|
||||||
this.debugService.emit('debug:repl_result', {
|
this.debugService.emit('debug:repl_result', {
|
||||||
source: 'llm',
|
source: 'llm',
|
||||||
code: result,
|
code: result,
|
||||||
@@ -625,7 +858,7 @@ export class Brain {
|
|||||||
parts.push(`[SCRIPT] Last eval ${ageMs}ms ago: return=${returnValue}; actions=${this.lastPlannerOutcome.actionCount} (ok=${this.lastPlannerOutcome.okCount}, err=${this.lastPlannerOutcome.errorCount}); logs=${logs}`)
|
parts.push(`[SCRIPT] Last eval ${ageMs}ms ago: return=${returnValue}; actions=${this.lastPlannerOutcome.actionCount} (ok=${this.lastPlannerOutcome.okCount}, err=${this.lastPlannerOutcome.errorCount}); logs=${logs}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push('[RUNTIME] Globals are refreshed every turn: snapshot, self, environment, social, threat, attention, autonomy, event, now, query, bot, mineflayer, mem, lastRun, prevRun, lastAction. Player gaze is available in environment.nearbyPlayersGaze when needed.')
|
parts.push('[RUNTIME] Globals are refreshed every turn: snapshot, self, environment, social, threat, attention, autonomy, event, now, query, bot, mineflayer, currentInput, llmLog, mem, lastRun, prevRun, lastAction. Player gaze is available in environment.nearbyPlayersGaze when needed.')
|
||||||
|
|
||||||
return parts.join('\n\n')
|
return parts.join('\n\n')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ describe('javaScriptPlanner', () => {
|
|||||||
expect(names).toContain('query')
|
expect(names).toContain('query')
|
||||||
expect(names).toContain('bot')
|
expect(names).toContain('bot')
|
||||||
expect(names).toContain('mineflayer')
|
expect(names).toContain('mineflayer')
|
||||||
|
expect(names).toContain('currentInput')
|
||||||
|
expect(names).toContain('llmLog')
|
||||||
|
|
||||||
const mem = descriptors.find(d => d.name === 'mem')
|
const mem = descriptors.find(d => d.name === 'mem')
|
||||||
expect(mem?.readonly).toBe(false)
|
expect(mem?.readonly).toBe(false)
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export interface RuntimeGlobals {
|
|||||||
snapshot: Record<string, unknown>
|
snapshot: Record<string, unknown>
|
||||||
mineflayer?: Mineflayer | null
|
mineflayer?: Mineflayer | null
|
||||||
bot?: unknown
|
bot?: unknown
|
||||||
|
currentInput?: unknown
|
||||||
|
llmLog?: unknown
|
||||||
llmInput?: {
|
llmInput?: {
|
||||||
systemPrompt: string
|
systemPrompt: string
|
||||||
userMessage: string
|
userMessage: string
|
||||||
@@ -191,6 +193,8 @@ export class JavaScriptPlanner {
|
|||||||
{ name: 'attention', kind: 'object', readonly: true },
|
{ name: 'attention', kind: 'object', readonly: true },
|
||||||
{ name: 'autonomy', kind: 'object', readonly: true },
|
{ name: 'autonomy', kind: 'object', readonly: true },
|
||||||
{ name: 'llmInput', kind: 'object', readonly: true },
|
{ name: 'llmInput', kind: 'object', readonly: true },
|
||||||
|
{ name: 'currentInput', kind: 'object', readonly: true },
|
||||||
|
{ name: 'llmLog', kind: 'object', readonly: true },
|
||||||
{ name: 'llmMessages', kind: 'object', readonly: true },
|
{ name: 'llmMessages', kind: 'object', readonly: true },
|
||||||
{ name: 'llmSystemPrompt', kind: 'string', readonly: true },
|
{ name: 'llmSystemPrompt', kind: 'string', readonly: true },
|
||||||
{ name: 'llmUserMessage', kind: 'string', readonly: true },
|
{ name: 'llmUserMessage', kind: 'string', readonly: true },
|
||||||
@@ -215,6 +219,8 @@ export class JavaScriptPlanner {
|
|||||||
attention: (globals.snapshot as Record<string, unknown>)?.attention,
|
attention: (globals.snapshot as Record<string, unknown>)?.attention,
|
||||||
autonomy: (globals.snapshot as Record<string, unknown>)?.autonomy,
|
autonomy: (globals.snapshot as Record<string, unknown>)?.autonomy,
|
||||||
llmInput: globals.llmInput ?? null,
|
llmInput: globals.llmInput ?? null,
|
||||||
|
currentInput: globals.currentInput ?? null,
|
||||||
|
llmLog: globals.llmLog ?? null,
|
||||||
llmMessages: globals.llmInput?.messages ?? [],
|
llmMessages: globals.llmInput?.messages ?? [],
|
||||||
llmSystemPrompt: globals.llmInput?.systemPrompt ?? '',
|
llmSystemPrompt: globals.llmInput?.systemPrompt ?? '',
|
||||||
llmUserMessage: globals.llmInput?.userMessage ?? '',
|
llmUserMessage: globals.llmInput?.userMessage ?? '',
|
||||||
@@ -372,6 +378,7 @@ export class JavaScriptPlanner {
|
|||||||
const snapshot = deepFreeze(toStructuredClone(globals.snapshot))
|
const snapshot = deepFreeze(toStructuredClone(globals.snapshot))
|
||||||
const event = deepFreeze(toStructuredClone(globals.event))
|
const event = deepFreeze(toStructuredClone(globals.event))
|
||||||
const llmInput = deepFreeze(toStructuredClone(globals.llmInput ?? null))
|
const llmInput = deepFreeze(toStructuredClone(globals.llmInput ?? null))
|
||||||
|
const currentInput = deepFreeze(toStructuredClone(globals.currentInput ?? null))
|
||||||
const query = globals.mineflayer ? createQueryRuntime(globals.mineflayer) : undefined
|
const query = globals.mineflayer ? createQueryRuntime(globals.mineflayer) : undefined
|
||||||
|
|
||||||
this.sandbox.prevRun = this.sandbox.lastRun ?? null
|
this.sandbox.prevRun = this.sandbox.lastRun ?? null
|
||||||
@@ -385,6 +392,8 @@ export class JavaScriptPlanner {
|
|||||||
this.sandbox.attention = snapshot.attention
|
this.sandbox.attention = snapshot.attention
|
||||||
this.sandbox.autonomy = snapshot.autonomy
|
this.sandbox.autonomy = snapshot.autonomy
|
||||||
this.sandbox.llmInput = llmInput
|
this.sandbox.llmInput = llmInput
|
||||||
|
this.sandbox.currentInput = currentInput
|
||||||
|
this.sandbox.llmLog = globals.llmLog ?? null
|
||||||
this.sandbox.llmMessages = llmInput?.messages ?? []
|
this.sandbox.llmMessages = llmInput?.messages ?? []
|
||||||
this.sandbox.llmSystemPrompt = llmInput?.systemPrompt ?? ''
|
this.sandbox.llmSystemPrompt = llmInput?.systemPrompt ?? ''
|
||||||
this.sandbox.llmUserMessage = llmInput?.userMessage ?? ''
|
this.sandbox.llmUserMessage = llmInput?.userMessage ?? ''
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { LlmLogEntry } from './llm-log'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { createLlmLogRuntime } from './llm-log'
|
||||||
|
|
||||||
|
function seedEntries(count: number): LlmLogEntry[] {
|
||||||
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
turnId: Math.floor(index / 2) + 1,
|
||||||
|
kind: index % 3 === 0 ? 'planner_error' : 'turn_input',
|
||||||
|
timestamp: 1000 + index,
|
||||||
|
eventType: 'perception',
|
||||||
|
sourceType: 'minecraft',
|
||||||
|
sourceId: index % 2 === 0 ? 'Alex' : 'Steve',
|
||||||
|
tags: index % 3 === 0 ? ['error', 'planner'] : ['input'],
|
||||||
|
text: index % 3 === 0 ? 'Invalid tool parameters' : 'Chat event',
|
||||||
|
metadata: { i: index },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('llmLog runtime', () => {
|
||||||
|
it('supports fluent filtering and latest slicing', () => {
|
||||||
|
const entries = seedEntries(20)
|
||||||
|
const llmLog = createLlmLogRuntime(() => entries)
|
||||||
|
const result = llmLog.query().errors().latest(3).list()
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3)
|
||||||
|
expect(result.every(entry => entry.tags.includes('error'))).toBe(true)
|
||||||
|
expect(result[0]?.timestamp).toBeGreaterThan(result[1]?.timestamp ?? 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports text/source filtering and counting', () => {
|
||||||
|
const entries = seedEntries(12)
|
||||||
|
const llmLog = createLlmLogRuntime(() => entries)
|
||||||
|
const count = llmLog
|
||||||
|
.query()
|
||||||
|
.textIncludes('invalid tool')
|
||||||
|
.whereSource('minecraft', 'Alex')
|
||||||
|
.count()
|
||||||
|
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns immutable copies from latest()', () => {
|
||||||
|
const entries = seedEntries(4)
|
||||||
|
const llmLog = createLlmLogRuntime(() => entries)
|
||||||
|
const recent = llmLog.latest(2)
|
||||||
|
recent[0]!.tags.push('mutated')
|
||||||
|
|
||||||
|
expect(entries[3]?.tags.includes('mutated')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
export type LlmLogEntryKind
|
||||||
|
= 'turn_input'
|
||||||
|
| 'llm_attempt'
|
||||||
|
| 'planner_result'
|
||||||
|
| 'planner_error'
|
||||||
|
| 'scheduler'
|
||||||
|
| 'feedback'
|
||||||
|
|
||||||
|
export interface LlmLogEntry {
|
||||||
|
id: number
|
||||||
|
turnId: number
|
||||||
|
kind: LlmLogEntryKind
|
||||||
|
timestamp: number
|
||||||
|
eventType: string
|
||||||
|
sourceType: string
|
||||||
|
sourceId: string
|
||||||
|
tags: string[]
|
||||||
|
text: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LlmLogQueryPatch {
|
||||||
|
predicates?: Array<(entry: LlmLogEntry) => boolean>
|
||||||
|
sorter?: (a: LlmLogEntry, b: LlmLogEntry) => number
|
||||||
|
sliceLatest?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class LlmLogQuery {
|
||||||
|
constructor(
|
||||||
|
private readonly entries: readonly LlmLogEntry[],
|
||||||
|
private readonly predicates: Array<(entry: LlmLogEntry) => boolean> = [],
|
||||||
|
private readonly sorter?: (a: LlmLogEntry, b: LlmLogEntry) => number,
|
||||||
|
private readonly sliceLatest?: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public whereKind(kind: LlmLogEntryKind | LlmLogEntryKind[]): LlmLogQuery {
|
||||||
|
const set = new Set(Array.isArray(kind) ? kind : [kind])
|
||||||
|
return this.clone({
|
||||||
|
predicates: [...this.predicates, entry => set.has(entry.kind)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public whereTag(tag: string | string[]): LlmLogQuery {
|
||||||
|
const set = new Set((Array.isArray(tag) ? tag : [tag]).map(item => item.toLowerCase()))
|
||||||
|
return this.clone({
|
||||||
|
predicates: [...this.predicates, entry => entry.tags.some(item => set.has(item.toLowerCase()))],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public whereSource(sourceType: string, sourceId?: string): LlmLogQuery {
|
||||||
|
return this.clone({
|
||||||
|
predicates: [...this.predicates, (entry) => {
|
||||||
|
if (entry.sourceType !== sourceType)
|
||||||
|
return false
|
||||||
|
if (sourceId !== undefined)
|
||||||
|
return entry.sourceId === sourceId
|
||||||
|
return true
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public errors(): LlmLogQuery {
|
||||||
|
return this.whereTag('error')
|
||||||
|
}
|
||||||
|
|
||||||
|
public turns(): LlmLogQuery {
|
||||||
|
return this.whereKind('turn_input')
|
||||||
|
}
|
||||||
|
|
||||||
|
public between(startTs: number, endTs: number): LlmLogQuery {
|
||||||
|
return this.clone({
|
||||||
|
predicates: [...this.predicates, entry => entry.timestamp >= startTs && entry.timestamp <= endTs],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public textIncludes(fragment: string): LlmLogQuery {
|
||||||
|
const needle = fragment.toLowerCase()
|
||||||
|
return this.clone({
|
||||||
|
predicates: [...this.predicates, entry => entry.text.toLowerCase().includes(needle)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public latest(count: number): LlmLogQuery {
|
||||||
|
return this.clone({
|
||||||
|
sorter: (a, b) => b.timestamp - a.timestamp,
|
||||||
|
sliceLatest: Math.max(1, Math.floor(count)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public list(): LlmLogEntry[] {
|
||||||
|
let result = this.entries.filter(entry => this.predicates.every(predicate => predicate(entry)))
|
||||||
|
if (this.sorter)
|
||||||
|
result = [...result].sort(this.sorter)
|
||||||
|
if (this.sliceLatest !== undefined)
|
||||||
|
result = result.slice(0, this.sliceLatest)
|
||||||
|
return result.map(entry => ({ ...entry, tags: [...entry.tags] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
public first(): LlmLogEntry | null {
|
||||||
|
return this.list()[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
public count(): number {
|
||||||
|
return this.list().length
|
||||||
|
}
|
||||||
|
|
||||||
|
private clone(patch: LlmLogQueryPatch): LlmLogQuery {
|
||||||
|
return new LlmLogQuery(
|
||||||
|
this.entries,
|
||||||
|
patch.predicates ?? this.predicates,
|
||||||
|
patch.sorter ?? this.sorter,
|
||||||
|
patch.sliceLatest ?? this.sliceLatest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLlmLogRuntime(getEntries: () => readonly LlmLogEntry[]) {
|
||||||
|
return {
|
||||||
|
get entries(): LlmLogEntry[] {
|
||||||
|
return getEntries().map(entry => ({ ...entry, tags: [...entry.tags] }))
|
||||||
|
},
|
||||||
|
query(): LlmLogQuery {
|
||||||
|
return new LlmLogQuery(getEntries())
|
||||||
|
},
|
||||||
|
latest(count = 20): LlmLogEntry[] {
|
||||||
|
return new LlmLogQuery(getEntries()).latest(count).list()
|
||||||
|
},
|
||||||
|
byId(id: number): LlmLogEntry | null {
|
||||||
|
const item = getEntries().find(entry => entry.id === id)
|
||||||
|
return item ? { ...item, tags: [...item.tags] } : null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,5 +19,7 @@ describe('generateBrainSystemPrompt', () => {
|
|||||||
expect(prompt).toContain('chat->feedback->chat')
|
expect(prompt).toContain('chat->feedback->chat')
|
||||||
expect(prompt).toContain('Query DSL')
|
expect(prompt).toContain('Query DSL')
|
||||||
expect(prompt).toContain('Heuristic composition examples')
|
expect(prompt).toContain('Heuristic composition examples')
|
||||||
|
expect(prompt).toContain('llmLog')
|
||||||
|
expect(prompt).toContain('Silent-eval pattern')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ You are an autonomous agent playing Minecraft.
|
|||||||
6. **Planner Runtime**: Your script runs in a persistent JavaScript context with a timeout.
|
6. **Planner Runtime**: Your script runs in a persistent JavaScript context with a timeout.
|
||||||
- Tool functions (listed below) execute actions and return results.
|
- Tool functions (listed below) execute actions and return results.
|
||||||
- Use \`await\` on tool calls when later logic depends on the result.
|
- Use \`await\` on tool calls when later logic depends on the result.
|
||||||
- Globals refreshed every turn: \`snapshot\`, \`self\`, \`environment\`, \`social\`, \`threat\`, \`attention\`, \`autonomy\`, \`event\`, \`now\`, \`query\`, \`bot\`, \`mineflayer\`.
|
- Globals refreshed every turn: \`snapshot\`, \`self\`, \`environment\`, \`social\`, \`threat\`, \`attention\`, \`autonomy\`, \`event\`, \`now\`, \`query\`, \`bot\`, \`mineflayer\`, \`currentInput\`, \`llmLog\`.
|
||||||
- Persistent globals: \`mem\` (cross-turn memory), \`lastRun\` (this run), \`prevRun\` (previous run), \`lastAction\` (latest action result), \`log(...)\`.
|
- Persistent globals: \`mem\` (cross-turn memory), \`lastRun\` (this run), \`prevRun\` (previous run), \`lastAction\` (latest action result), \`log(...)\`.
|
||||||
- Last script outcome is also echoed in the next turn as \`[SCRIPT]\` context (return value, action stats, and logs).
|
- Last script outcome is also echoed in the next turn as \`[SCRIPT]\` context (return value, action stats, and logs).
|
||||||
- Maximum actions per turn: 5.
|
- Maximum actions per turn: 5.
|
||||||
@@ -167,6 +167,25 @@ Heuristic composition examples (encouraged):
|
|||||||
- \`if (orePressure > 3 && !hostileClose) { /* mine-oriented plan */ }\`
|
- \`if (orePressure > 3 && !hostileClose) { /* mine-oriented plan */ }\`
|
||||||
- Verify assumptions with \`query\` first, then call action tools.
|
- Verify assumptions with \`query\` first, then call action tools.
|
||||||
|
|
||||||
|
# Input + Runtime Log Objects
|
||||||
|
- \`currentInput\`: structured object for the current turn input (event metadata, user message, prompt preview, attempt/model info).
|
||||||
|
- \`llmLog\`: runtime ring-log of prior turn envelopes/results/errors with metadata.
|
||||||
|
- \`llmLog.entries\` for raw entries.
|
||||||
|
- \`llmLog.query()\` fluent lookup (\`whereKind\`, \`whereTag\`, \`whereSource\`, \`errors\`, \`turns\`, \`latest\`, \`between\`, \`textIncludes\`, \`list\`, \`first\`, \`count\`).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- \`const recentErrors = llmLog.query().errors().latest(5).list()\`
|
||||||
|
- \`const lastNoAction = llmLog.query().whereTag("no_actions").latest(1).first()\`
|
||||||
|
- \`const sameSourceTurns = llmLog.query().turns().whereSource(currentInput.event.sourceType, currentInput.event.sourceId).latest(3).list()\`
|
||||||
|
- \`const parseIssues = llmLog.query().textIncludes("Invalid tool parameters").latest(10).list()\`
|
||||||
|
|
||||||
|
Silent-eval pattern (strongly encouraged):
|
||||||
|
- Use no-action evaluation turns to inspect uncertain values before committing to world actions.
|
||||||
|
- Good pattern:
|
||||||
|
- Turn A: \`let blocksToMine = someFunc(); blocksToMine\`
|
||||||
|
- Turn B: inspect \`[SCRIPT]\` return / \`llmLog\`, then act: \`await collectBlocks({ type: ..., num: ... })\`
|
||||||
|
- Prefer this when a wrong action would be costly, dangerous, or hard to undo.
|
||||||
|
|
||||||
# Response Format
|
# Response Format
|
||||||
You must respond with JavaScript only (no markdown code fences).
|
You must respond with JavaScript only (no markdown code fences).
|
||||||
Call tool functions directly.
|
Call tool functions directly.
|
||||||
@@ -211,6 +230,7 @@ Common patterns:
|
|||||||
- Treat action results as potentially unreliable; check outcomes against \`snapshot\`/feedback before committing to the next step.
|
- Treat action results as potentially unreliable; check outcomes against \`snapshot\`/feedback before committing to the next step.
|
||||||
- Prefer deterministic scripts: no random branching unless needed.
|
- Prefer deterministic scripts: no random branching unless needed.
|
||||||
- Keep per-turn scripts short and focused on one tactical objective.
|
- Keep per-turn scripts short and focused on one tactical objective.
|
||||||
|
- Prefer "evaluate then act" loops: first compute and return candidate values (no actions), then perform tools in the next turn using confirmed values.
|
||||||
- If you hit repeated failures with no progress, call \`await giveUp({ reason, cooldown_seconds })\` once instead of retry-spamming.
|
- If you hit repeated failures with no progress, call \`await giveUp({ reason, cooldown_seconds })\` once instead of retry-spamming.
|
||||||
- Treat \`environment.nearbyPlayersGaze\` as a weak hint, not a command. Never move solely because someone looked somewhere unless they also gave a clear instruction.
|
- Treat \`environment.nearbyPlayersGaze\` as a weak hint, not a command. Never move solely because someone looked somewhere unless they also gave a clear instruction.
|
||||||
- Use \`followPlayer\` to set idle auto-follow and \`clearFollowTarget\` before independent exploration.
|
- Use \`followPlayer\` to set idle auto-follow and \`clearFollowTarget\` before independent exploration.
|
||||||
|
|||||||
Reference in New Issue
Block a user