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 { PlannerGlobalDescriptor } from './js-planner'
|
||||
import type { LLMAgent } from './llm-agent'
|
||||
import type { LlmLogEntry, LlmLogEntryKind } from './llm-log'
|
||||
import type { CancellationToken } from './task-state'
|
||||
|
||||
import { config } from '../../composables/config'
|
||||
import { DebugService } from '../../debug'
|
||||
import { buildConsciousContextView } from './context-view'
|
||||
import { JavaScriptPlanner } from './js-planner'
|
||||
import { createLlmLogRuntime } from './llm-log'
|
||||
import {
|
||||
isLikelyAuthOrBadArgError,
|
||||
isRateLimitError,
|
||||
@@ -73,10 +75,48 @@ interface LlmInputSnapshot {
|
||||
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 {
|
||||
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'
|
||||
|
||||
export class Brain {
|
||||
@@ -97,6 +137,11 @@ export class Brain {
|
||||
private conversationHistory: Message[] = []
|
||||
private lastLlmInputSnapshot: LlmInputSnapshot | 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) {
|
||||
this.debugService = DebugService.getInstance()
|
||||
@@ -120,6 +165,19 @@ export class Brain {
|
||||
// Action Feedback Handler
|
||||
this.deps.taskExecutor.on('action:completed', async ({ action, result }) => {
|
||||
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) {
|
||||
return
|
||||
@@ -142,6 +200,18 @@ export class Brain {
|
||||
|
||||
this.deps.taskExecutor.on('action:failed', async ({ action, error }) => {
|
||||
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, {
|
||||
type: 'feedback',
|
||||
payload: { status: 'failure', action, error: error.message || error },
|
||||
@@ -160,20 +230,15 @@ export class Brain {
|
||||
|
||||
public getReplState(): { variables: PlannerGlobalDescriptor[], updatedAt: number } {
|
||||
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(
|
||||
this.deps.taskExecutor.getAvailableActions(),
|
||||
{
|
||||
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,
|
||||
},
|
||||
this.createRuntimeGlobals(replEvent, snapshot as unknown as Record<string, unknown>),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -208,18 +273,12 @@ export class Brain {
|
||||
const runResult = await this.planner.evaluate(
|
||||
codeToEvaluate,
|
||||
this.deps.taskExecutor.getAvailableActions(),
|
||||
{
|
||||
event: {
|
||||
type: 'system_alert',
|
||||
payload: { source: 'debug-repl' },
|
||||
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,
|
||||
},
|
||||
this.createRuntimeGlobals({
|
||||
type: 'system_alert',
|
||||
payload: { source: 'debug-repl' },
|
||||
source: { type: 'system', id: 'debug-repl' },
|
||||
timestamp: Date.now(),
|
||||
}, snapshot as unknown as Record<string, unknown>),
|
||||
async (action: ActionInstruction) => {
|
||||
const actionDef = actionDefs.get(action.tool)
|
||||
if (actionDef?.followControl === 'detach')
|
||||
@@ -282,15 +341,70 @@ export class Brain {
|
||||
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(
|
||||
bot: MineflayerWithAgents,
|
||||
triggeringEvent: BotEvent,
|
||||
turnId: number,
|
||||
returnValue: string | undefined,
|
||||
logs: string[],
|
||||
): void {
|
||||
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.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
|
||||
}
|
||||
|
||||
@@ -305,6 +419,18 @@ export class Brain {
|
||||
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')
|
||||
void this.enqueueEvent(bot, followupEvent).catch(err =>
|
||||
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)
|
||||
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
|
||||
const maxAttempts = 3
|
||||
@@ -400,6 +556,24 @@ export class Brain {
|
||||
updatedAt: Date.now(),
|
||||
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()
|
||||
|
||||
@@ -426,6 +600,25 @@ export class Brain {
|
||||
model: config.openai.model,
|
||||
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({
|
||||
status: 'processing',
|
||||
@@ -455,6 +648,15 @@ export class Brain {
|
||||
// 4. Parse & Execute
|
||||
if (!result) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -479,13 +681,7 @@ export class Brain {
|
||||
const runResult = await this.planner.evaluate(
|
||||
codeToEvaluate,
|
||||
this.deps.taskExecutor.getAvailableActions(),
|
||||
{
|
||||
event,
|
||||
snapshot: snapshot as unknown as Record<string, unknown>,
|
||||
mineflayer: bot,
|
||||
bot: bot.bot,
|
||||
llmInput: this.lastLlmInputSnapshot,
|
||||
},
|
||||
this.createRuntimeGlobals(event, snapshot as unknown as Record<string, unknown>, bot),
|
||||
async (action: ActionInstruction) => {
|
||||
if (action.tool === 'chat' && !this.shouldAllowChatForEvent(event, snapshot.self.health)) {
|
||||
return 'Chat suppressed: no direct user prompt for chat this turn'
|
||||
@@ -518,6 +714,31 @@ export class Brain {
|
||||
logs: runResult.logs.slice(-3),
|
||||
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')) {
|
||||
this.debugService.emit('debug:repl_result', {
|
||||
@@ -530,7 +751,7 @@ export class Brain {
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
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)')
|
||||
return
|
||||
@@ -559,6 +780,18 @@ export class Brain {
|
||||
}
|
||||
catch (err) {
|
||||
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', {
|
||||
source: 'llm',
|
||||
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('[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')
|
||||
}
|
||||
|
||||
@@ -168,6 +168,8 @@ describe('javaScriptPlanner', () => {
|
||||
expect(names).toContain('query')
|
||||
expect(names).toContain('bot')
|
||||
expect(names).toContain('mineflayer')
|
||||
expect(names).toContain('currentInput')
|
||||
expect(names).toContain('llmLog')
|
||||
|
||||
const mem = descriptors.find(d => d.name === 'mem')
|
||||
expect(mem?.readonly).toBe(false)
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface RuntimeGlobals {
|
||||
snapshot: Record<string, unknown>
|
||||
mineflayer?: Mineflayer | null
|
||||
bot?: unknown
|
||||
currentInput?: unknown
|
||||
llmLog?: unknown
|
||||
llmInput?: {
|
||||
systemPrompt: string
|
||||
userMessage: string
|
||||
@@ -191,6 +193,8 @@ export class JavaScriptPlanner {
|
||||
{ name: 'attention', kind: 'object', readonly: true },
|
||||
{ name: 'autonomy', 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: 'llmSystemPrompt', 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,
|
||||
autonomy: (globals.snapshot as Record<string, unknown>)?.autonomy,
|
||||
llmInput: globals.llmInput ?? null,
|
||||
currentInput: globals.currentInput ?? null,
|
||||
llmLog: globals.llmLog ?? null,
|
||||
llmMessages: globals.llmInput?.messages ?? [],
|
||||
llmSystemPrompt: globals.llmInput?.systemPrompt ?? '',
|
||||
llmUserMessage: globals.llmInput?.userMessage ?? '',
|
||||
@@ -372,6 +378,7 @@ export class JavaScriptPlanner {
|
||||
const snapshot = deepFreeze(toStructuredClone(globals.snapshot))
|
||||
const event = deepFreeze(toStructuredClone(globals.event))
|
||||
const llmInput = deepFreeze(toStructuredClone(globals.llmInput ?? null))
|
||||
const currentInput = deepFreeze(toStructuredClone(globals.currentInput ?? null))
|
||||
const query = globals.mineflayer ? createQueryRuntime(globals.mineflayer) : undefined
|
||||
|
||||
this.sandbox.prevRun = this.sandbox.lastRun ?? null
|
||||
@@ -385,6 +392,8 @@ export class JavaScriptPlanner {
|
||||
this.sandbox.attention = snapshot.attention
|
||||
this.sandbox.autonomy = snapshot.autonomy
|
||||
this.sandbox.llmInput = llmInput
|
||||
this.sandbox.currentInput = currentInput
|
||||
this.sandbox.llmLog = globals.llmLog ?? null
|
||||
this.sandbox.llmMessages = llmInput?.messages ?? []
|
||||
this.sandbox.llmSystemPrompt = llmInput?.systemPrompt ?? ''
|
||||
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('Query DSL')
|
||||
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.
|
||||
- Tool functions (listed below) execute actions and return results.
|
||||
- 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(...)\`.
|
||||
- Last script outcome is also echoed in the next turn as \`[SCRIPT]\` context (return value, action stats, and logs).
|
||||
- Maximum actions per turn: 5.
|
||||
@@ -167,6 +167,25 @@ Heuristic composition examples (encouraged):
|
||||
- \`if (orePressure > 3 && !hostileClose) { /* mine-oriented plan */ }\`
|
||||
- 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
|
||||
You must respond with JavaScript only (no markdown code fences).
|
||||
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.
|
||||
- Prefer deterministic scripts: no random branching unless needed.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user