feat(minecraft): execute tools from dashboard
This commit is contained in:
@@ -20,7 +20,6 @@ export class MineflayerPerceptionCollector {
|
||||
}> = []
|
||||
|
||||
private lastSelfHealth: number | null = null
|
||||
|
||||
private lastStatsAt = 0
|
||||
private stats: Record<string, number> = {}
|
||||
private sneakingState: Map<string, boolean> = new Map()
|
||||
@@ -36,214 +35,12 @@ export class MineflayerPerceptionCollector {
|
||||
public init(bot: MineflayerWithAgents): void {
|
||||
this.bot = bot
|
||||
this.lastSelfHealth = bot.bot.health
|
||||
|
||||
this.lastStatsAt = Date.now()
|
||||
this.stats = {}
|
||||
|
||||
this.deps.logger.withFields({ maxDistance: this.deps.maxDistance }).log('MineflayerPerceptionCollector: init')
|
||||
|
||||
this.onBot('entityMoved', (entity: any) => {
|
||||
const now = Date.now()
|
||||
const dist = this.distanceTo(entity)
|
||||
if (dist === null || dist > this.deps.maxDistance)
|
||||
return
|
||||
|
||||
// Ignore self
|
||||
if (entity.username === this.bot?.bot.username)
|
||||
return
|
||||
|
||||
const entityId = this.entityId(entity)
|
||||
|
||||
const event: SightedEntityMovedEvent = {
|
||||
modality: 'sighted',
|
||||
kind: 'entity_moved',
|
||||
entityType: entity?.type === 'player' ? 'player' : 'mob',
|
||||
entityId,
|
||||
displayName: entity?.username,
|
||||
distance: dist,
|
||||
hasLineOfSight: true,
|
||||
timestamp: now,
|
||||
source: 'minecraft',
|
||||
pos: entity?.position,
|
||||
}
|
||||
|
||||
this.deps.emitRaw(event)
|
||||
this.bumpStat('sighted.entity_moved')
|
||||
this.maybeLogStats()
|
||||
})
|
||||
|
||||
this.onBot('entitySwingArm', (entity: any) => {
|
||||
const now = Date.now()
|
||||
const dist = this.distanceTo(entity)
|
||||
if (dist === null || dist > this.deps.maxDistance)
|
||||
return
|
||||
|
||||
// Ignore self
|
||||
if (entity.username === this.bot?.bot.username)
|
||||
return
|
||||
|
||||
const event: SightedArmSwingEvent = {
|
||||
modality: 'sighted',
|
||||
kind: 'arm_swing',
|
||||
entityType: 'player',
|
||||
entityId: this.entityId(entity),
|
||||
displayName: entity?.username,
|
||||
distance: dist,
|
||||
hasLineOfSight: true,
|
||||
timestamp: now,
|
||||
source: 'minecraft',
|
||||
pos: entity?.position,
|
||||
}
|
||||
|
||||
this.deps.emitRaw(event)
|
||||
this.bumpStat('sighted.arm_swing')
|
||||
this.maybeLogStats()
|
||||
})
|
||||
|
||||
this.onBot('entityUpdate', (entity: any) => {
|
||||
if (!entity || entity.type !== 'player')
|
||||
return
|
||||
|
||||
// Ignore self
|
||||
if (entity.username === this.bot?.bot.username)
|
||||
return
|
||||
|
||||
const entityId = this.entityId(entity)
|
||||
const flags = entity?.metadata?.[0]
|
||||
// Bit 1 (0x02) is sneaking
|
||||
const isSneaking = typeof flags === 'number' ? !!(flags & 0x02) : false
|
||||
|
||||
// Check if state actually changed
|
||||
const lastState = this.sneakingState.get(entityId)
|
||||
if (lastState === isSneaking) {
|
||||
return
|
||||
}
|
||||
this.sneakingState.set(entityId, isSneaking)
|
||||
|
||||
const now = Date.now()
|
||||
const dist = this.distanceTo(entity)
|
||||
if (dist === null || dist > this.deps.maxDistance)
|
||||
return
|
||||
|
||||
const event: SightedSneakToggleEvent = {
|
||||
modality: 'sighted',
|
||||
kind: 'sneak_toggle',
|
||||
entityType: 'player',
|
||||
entityId,
|
||||
displayName: entity?.username,
|
||||
distance: dist,
|
||||
hasLineOfSight: true,
|
||||
sneaking: isSneaking,
|
||||
timestamp: now,
|
||||
source: 'minecraft',
|
||||
pos: entity?.position,
|
||||
}
|
||||
|
||||
this.deps.logger.withFields({ entity: entity.username, sneaking: isSneaking }).log('MineflayerPerceptionCollector: sneak_toggle')
|
||||
|
||||
this.deps.emitRaw(event)
|
||||
this.bumpStat('sighted.sneak_toggle')
|
||||
this.maybeLogStats()
|
||||
})
|
||||
|
||||
this.onBot('soundEffectHeard', (soundId: string, pos: Vec3) => {
|
||||
const now = Date.now()
|
||||
if (!pos)
|
||||
return
|
||||
|
||||
const dist = this.distanceToPos(pos)
|
||||
if (dist === null || dist > this.deps.maxDistance)
|
||||
return
|
||||
|
||||
const event: HeardSoundEvent = {
|
||||
modality: 'heard',
|
||||
kind: 'sound',
|
||||
soundId,
|
||||
distance: dist,
|
||||
timestamp: now,
|
||||
source: 'minecraft',
|
||||
pos,
|
||||
}
|
||||
|
||||
this.deps.emitRaw(event)
|
||||
})
|
||||
|
||||
// Felt: damage taken (self health decreased)
|
||||
this.onBot('health', () => {
|
||||
if (!this.bot)
|
||||
return
|
||||
|
||||
const now = Date.now()
|
||||
const current = this.bot.bot.health
|
||||
const prev = this.lastSelfHealth
|
||||
this.lastSelfHealth = current
|
||||
if (typeof prev !== 'number')
|
||||
return
|
||||
|
||||
if (current >= prev)
|
||||
return
|
||||
|
||||
const event: FeltDamageTakenEvent = {
|
||||
modality: 'felt',
|
||||
kind: 'damage_taken',
|
||||
amount: prev - current,
|
||||
timestamp: now,
|
||||
source: 'minecraft',
|
||||
}
|
||||
|
||||
this.deps.emitRaw(event)
|
||||
this.bumpStat('felt.damage_taken')
|
||||
this.maybeLogStats()
|
||||
})
|
||||
|
||||
// Felt: item collected (best-effort; depends on mineflayer version/events)
|
||||
this.onBot('playerCollect', (collector: any, collected: any) => {
|
||||
if (!this.bot)
|
||||
return
|
||||
if (!collector)
|
||||
return
|
||||
if (collector.username !== this.bot.bot.username)
|
||||
return
|
||||
|
||||
const now = Date.now()
|
||||
const itemName = String(collected?.name ?? collected?.displayName ?? collected?.type ?? 'unknown')
|
||||
|
||||
const event: FeltItemCollectedEvent = {
|
||||
modality: 'felt',
|
||||
kind: 'item_collected',
|
||||
itemName,
|
||||
timestamp: now,
|
||||
source: 'minecraft',
|
||||
}
|
||||
|
||||
this.deps.emitRaw(event)
|
||||
this.bumpStat('felt.item_collected')
|
||||
this.maybeLogStats()
|
||||
})
|
||||
|
||||
this.onBot('entityCollect', (collector: any, collected: any) => {
|
||||
if (!this.bot)
|
||||
return
|
||||
if (!collector)
|
||||
return
|
||||
if (collector.username !== this.bot.bot.username)
|
||||
return
|
||||
|
||||
const now = Date.now()
|
||||
const itemName = String(collected?.name ?? collected?.displayName ?? collected?.type ?? 'unknown')
|
||||
|
||||
const event: FeltItemCollectedEvent = {
|
||||
modality: 'felt',
|
||||
kind: 'item_collected',
|
||||
itemName,
|
||||
timestamp: now,
|
||||
source: 'minecraft',
|
||||
}
|
||||
|
||||
this.deps.emitRaw(event)
|
||||
this.bumpStat('felt.item_collected')
|
||||
this.maybeLogStats()
|
||||
})
|
||||
this.registerEventHandlers()
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
@@ -268,6 +65,211 @@ export class MineflayerPerceptionCollector {
|
||||
this.bot = null
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Event Handler Registration
|
||||
// ========================================
|
||||
|
||||
private registerEventHandlers(): void {
|
||||
this.onBot('entityMoved', entity => this.handleEntityMoved(entity))
|
||||
this.onBot('entitySwingArm', entity => this.handleEntitySwingArm(entity))
|
||||
this.onBot('entityUpdate', entity => this.handleEntityUpdate(entity))
|
||||
this.onBot('soundEffectHeard', (soundId, pos) => this.handleSoundHeard(soundId, pos))
|
||||
this.onBot('health', () => this.handleHealthChange())
|
||||
this.onBot('playerCollect', (collector, collected) => this.handleItemCollected(collector, collected))
|
||||
this.onBot('entityCollect', (collector, collected) => this.handleItemCollected(collector, collected))
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Sighted Event Handlers
|
||||
// ========================================
|
||||
|
||||
private handleEntityMoved(entity: any): void {
|
||||
if (!this.isValidEntityInRange(entity))
|
||||
return
|
||||
|
||||
const event: SightedEntityMovedEvent = {
|
||||
modality: 'sighted',
|
||||
kind: 'entity_moved',
|
||||
entityType: entity?.type === 'player' ? 'player' : 'mob',
|
||||
entityId: this.entityId(entity),
|
||||
displayName: entity?.username,
|
||||
distance: this.distanceTo(entity)!,
|
||||
hasLineOfSight: true,
|
||||
timestamp: Date.now(),
|
||||
source: 'minecraft',
|
||||
pos: entity?.position,
|
||||
}
|
||||
|
||||
this.emitEvent(event, 'sighted.entity_moved')
|
||||
}
|
||||
|
||||
private handleEntitySwingArm(entity: any): void {
|
||||
if (!this.isValidEntityInRange(entity))
|
||||
return
|
||||
|
||||
const event: SightedArmSwingEvent = {
|
||||
modality: 'sighted',
|
||||
kind: 'arm_swing',
|
||||
entityType: 'player',
|
||||
entityId: this.entityId(entity),
|
||||
displayName: entity?.username,
|
||||
distance: this.distanceTo(entity)!,
|
||||
hasLineOfSight: true,
|
||||
timestamp: Date.now(),
|
||||
source: 'minecraft',
|
||||
pos: entity?.position,
|
||||
}
|
||||
|
||||
this.emitEvent(event, 'sighted.arm_swing')
|
||||
}
|
||||
|
||||
private handleEntityUpdate(entity: any): void {
|
||||
if (!entity || entity.type !== 'player')
|
||||
return
|
||||
|
||||
if (this.isSelfEntity(entity))
|
||||
return
|
||||
|
||||
const entityId = this.entityId(entity)
|
||||
const isSneaking = this.extractSneakingState(entity)
|
||||
|
||||
if (!this.hasSneakingStateChanged(entityId, isSneaking))
|
||||
return
|
||||
|
||||
this.sneakingState.set(entityId, isSneaking)
|
||||
|
||||
const dist = this.distanceTo(entity)
|
||||
if (dist === null || dist > this.deps.maxDistance)
|
||||
return
|
||||
|
||||
const event: SightedSneakToggleEvent = {
|
||||
modality: 'sighted',
|
||||
kind: 'sneak_toggle',
|
||||
entityType: 'player',
|
||||
entityId,
|
||||
displayName: entity?.username,
|
||||
distance: dist,
|
||||
hasLineOfSight: true,
|
||||
sneaking: isSneaking,
|
||||
timestamp: Date.now(),
|
||||
source: 'minecraft',
|
||||
pos: entity?.position,
|
||||
}
|
||||
|
||||
this.emitEvent(event, 'sighted.sneak_toggle')
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Heard Event Handlers
|
||||
// ========================================
|
||||
|
||||
private handleSoundHeard(soundId: string, pos: Vec3): void {
|
||||
if (!pos)
|
||||
return
|
||||
|
||||
const dist = this.distanceToPos(pos)
|
||||
if (dist === null || dist > this.deps.maxDistance)
|
||||
return
|
||||
|
||||
const event: HeardSoundEvent = {
|
||||
modality: 'heard',
|
||||
kind: 'sound',
|
||||
soundId,
|
||||
distance: dist,
|
||||
timestamp: Date.now(),
|
||||
source: 'minecraft',
|
||||
pos,
|
||||
}
|
||||
|
||||
this.deps.emitRaw(event)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Felt Event Handlers
|
||||
// ========================================
|
||||
|
||||
private handleHealthChange(): void {
|
||||
if (!this.bot)
|
||||
return
|
||||
|
||||
const current = this.bot.bot.health
|
||||
const prev = this.lastSelfHealth
|
||||
this.lastSelfHealth = current
|
||||
|
||||
if (typeof prev !== 'number' || current >= prev)
|
||||
return
|
||||
|
||||
const event: FeltDamageTakenEvent = {
|
||||
modality: 'felt',
|
||||
kind: 'damage_taken',
|
||||
amount: prev - current,
|
||||
timestamp: Date.now(),
|
||||
source: 'minecraft',
|
||||
}
|
||||
|
||||
this.emitEvent(event, 'felt.damage_taken')
|
||||
}
|
||||
|
||||
private handleItemCollected(collector: any, collected: any): void {
|
||||
if (!this.bot || !collector)
|
||||
return
|
||||
|
||||
if (collector.username !== this.bot.bot.username)
|
||||
return
|
||||
|
||||
const itemName = String(collected?.name ?? collected?.displayName ?? collected?.type ?? 'unknown')
|
||||
|
||||
const event: FeltItemCollectedEvent = {
|
||||
modality: 'felt',
|
||||
kind: 'item_collected',
|
||||
itemName,
|
||||
timestamp: Date.now(),
|
||||
source: 'minecraft',
|
||||
}
|
||||
|
||||
this.emitEvent(event, 'felt.item_collected')
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Validation Helpers
|
||||
// ========================================
|
||||
|
||||
private isValidEntityInRange(entity: any): boolean {
|
||||
const dist = this.distanceTo(entity)
|
||||
if (dist === null || dist > this.deps.maxDistance)
|
||||
return false
|
||||
|
||||
if (this.isSelfEntity(entity))
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private isSelfEntity(entity: any): boolean {
|
||||
return entity.username === this.bot?.bot.username
|
||||
}
|
||||
|
||||
private extractSneakingState(entity: any): boolean {
|
||||
const flags = entity?.metadata?.[0]
|
||||
// Bit 1 (0x02) is sneaking
|
||||
return typeof flags === 'number' ? !!(flags & 0x02) : false
|
||||
}
|
||||
|
||||
private hasSneakingStateChanged(entityId: string, isSneaking: boolean): boolean {
|
||||
const lastState = this.sneakingState.get(entityId)
|
||||
return lastState !== isSneaking
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Utilities
|
||||
// ========================================
|
||||
|
||||
private emitEvent(event: RawPerceptionEvent, statKey: string): void {
|
||||
this.deps.emitRaw(event)
|
||||
this.bumpStat(statKey)
|
||||
this.maybeLogStats()
|
||||
}
|
||||
|
||||
private bumpStat(key: string): void {
|
||||
this.stats[key] = (this.stats[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ trigger:
|
||||
entityType: player
|
||||
|
||||
accumulator:
|
||||
threshold: 5
|
||||
threshold: 14
|
||||
window: 2s
|
||||
|
||||
signal:
|
||||
|
||||
@@ -200,6 +200,18 @@ export class DebugService {
|
||||
case 'reflex':
|
||||
this.emitReflexState(payload as Omit<ReflexStateEvent, 'timestamp'>)
|
||||
break
|
||||
case 'debug:tools_list':
|
||||
this.server.broadcast({
|
||||
type: 'debug:tools_list',
|
||||
payload: payload as { tools: any[] },
|
||||
})
|
||||
break
|
||||
case 'debug:tool_result':
|
||||
this.server.broadcast({
|
||||
type: 'debug:tool_result',
|
||||
payload: payload as any,
|
||||
})
|
||||
break
|
||||
default:
|
||||
// For unknown types, emit as log
|
||||
this.log('DEBUG', `Unknown event type: ${type}`, { payload })
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { ZodObject, ZodType } from 'zod'
|
||||
|
||||
import type { Mineflayer } from '../libs/mineflayer'
|
||||
import type { ToolDefinition, ToolParameter } from './types'
|
||||
|
||||
import { actionsList } from '../agents/action/tools'
|
||||
import { DebugService } from './debug-service'
|
||||
|
||||
export class ToolExecutor {
|
||||
private mineflayer: Mineflayer
|
||||
private debugService: DebugService
|
||||
|
||||
constructor(mineflayer: Mineflayer) {
|
||||
this.mineflayer = mineflayer
|
||||
this.debugService = DebugService.getInstance()
|
||||
console.log('[ToolExecutor] Initializing ToolExecutor')
|
||||
this.setupHandlers()
|
||||
}
|
||||
|
||||
private setupHandlers(): void {
|
||||
console.log('[ToolExecutor] Key registered for request_tools')
|
||||
|
||||
// Handle tool list request
|
||||
this.debugService.onCommand('request_tools', () => {
|
||||
console.log('[ToolExecutor] Received request_tools command')
|
||||
this.sendToolsList()
|
||||
})
|
||||
|
||||
// Handle tool execution
|
||||
this.debugService.onCommand('execute_tool', (cmd) => {
|
||||
if (cmd.type === 'execute_tool') {
|
||||
console.log(`[ToolExecutor] Executing tool: ${cmd.payload.toolName}`)
|
||||
this.executeTool(cmd.payload.toolName, cmd.payload.params)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private sendToolsList(): void {
|
||||
try {
|
||||
const tools = this.extractToolDefinitions()
|
||||
console.log(`[ToolExecutor] Sending ${tools.length} tools`)
|
||||
this.debugService.emit('debug:tools_list', { tools })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[ToolExecutor] Error sending tool list:', err)
|
||||
}
|
||||
}
|
||||
|
||||
private async executeTool(toolName: string, params: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
// Check if action is blocked
|
||||
// TODO: Add check for running agent if needed
|
||||
|
||||
const action = actionsList.find(a => a.name === toolName)
|
||||
if (!action) {
|
||||
throw new Error(`Tool '${toolName}' not found`)
|
||||
}
|
||||
|
||||
// Validate params
|
||||
const validated = action.schema.parse(params)
|
||||
|
||||
// Execute
|
||||
// The perform function in existing tools often returns a function that returns a Promise (or value)
|
||||
// perform: (mineflayer) => async (args) => result
|
||||
const performer = action.perform(this.mineflayer)
|
||||
|
||||
const args: any[] = []
|
||||
const shape = (action.schema as any).shape
|
||||
for (const key in shape) {
|
||||
if (Object.prototype.hasOwnProperty.call(validated, key)) {
|
||||
args.push((validated as any)[key])
|
||||
}
|
||||
}
|
||||
|
||||
const result = await performer(...args)
|
||||
|
||||
this.debugService.emit('debug:tool_result', {
|
||||
toolName,
|
||||
params,
|
||||
result: typeof result === 'string' ? result : JSON.stringify(result),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
catch (err: unknown) {
|
||||
this.debugService.emit('debug:tool_result', {
|
||||
toolName,
|
||||
params,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private extractToolDefinitions(): ToolDefinition[] {
|
||||
return actionsList.map(action => ({
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
params: this.extractParamsFromSchema(action.schema),
|
||||
}))
|
||||
}
|
||||
|
||||
private extractParamsFromSchema(schema: ZodObject<any>): ToolParameter[] {
|
||||
if (!schema || !schema.shape)
|
||||
return []
|
||||
|
||||
const shape = schema.shape
|
||||
const params: ToolParameter[] = []
|
||||
|
||||
for (const [name, zodType] of Object.entries(shape)) {
|
||||
const def = this.getZodDef(zodType as ZodType<any>)
|
||||
|
||||
params.push({
|
||||
name,
|
||||
type: def.typeName,
|
||||
description: def.description,
|
||||
min: def.min,
|
||||
max: def.max,
|
||||
default: def.defaultValue,
|
||||
})
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// Helper to extract metadata from Zod types
|
||||
private getZodDef(zodType: ZodType<any>): { typeName: 'string' | 'number' | 'boolean', description?: string, min?: number, max?: number, defaultValue?: any } {
|
||||
let typeName: 'string' | 'number' | 'boolean' = 'string'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let curr: any = zodType
|
||||
const description = curr.description
|
||||
|
||||
let min: number | undefined
|
||||
let max: number | undefined
|
||||
let defaultValue: any
|
||||
|
||||
// Helper to get type identifier
|
||||
const getTypeId = (t: any) => t.constructor.name || t._def?.typeName
|
||||
|
||||
// Unwrap effects/optional/nullable/default to get inner type
|
||||
let infiniteLoopGuard = 0
|
||||
while (infiniteLoopGuard++ < 10) {
|
||||
const typeId = getTypeId(curr)
|
||||
if (typeId === 'ZodOptional' || typeId === 'ZodNullable') {
|
||||
curr = curr._def.innerType
|
||||
}
|
||||
else if (typeId === 'ZodEffects') {
|
||||
curr = curr._def.schema
|
||||
}
|
||||
else if (typeId === 'ZodDefault') {
|
||||
defaultValue = curr._def.defaultValue()
|
||||
curr = curr._def.innerType
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const typeId = getTypeId(curr)
|
||||
|
||||
// Debug logging for type checking
|
||||
// console.log(`[ToolExecutor] Type check: ${description} -> ${typeId}`)
|
||||
|
||||
if (typeId === 'ZodString') {
|
||||
typeName = 'string'
|
||||
}
|
||||
else if (typeId === 'ZodNumber') {
|
||||
typeName = 'number'
|
||||
// Try to extract min/max from checks
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (curr._def.checks) {
|
||||
for (const check of (curr as any)._def.checks) {
|
||||
if (check.kind === 'min')
|
||||
min = check.value
|
||||
if (check.kind === 'max')
|
||||
max = check.value
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (typeId === 'ZodBoolean') {
|
||||
typeName = 'boolean'
|
||||
}
|
||||
|
||||
return { typeName, description, min, max, defaultValue }
|
||||
}
|
||||
}
|
||||
|
||||
export function setupToolExecutor(mineflayer: Mineflayer): ToolExecutor {
|
||||
return new ToolExecutor(mineflayer)
|
||||
}
|
||||
@@ -101,6 +101,41 @@ export interface TraceBatchEvent {
|
||||
}
|
||||
|
||||
// Union type for all server events
|
||||
|
||||
// ============================================================
|
||||
// Tool types
|
||||
// ============================================================
|
||||
|
||||
export interface ToolParameter {
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean'
|
||||
description?: string
|
||||
required?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
default?: unknown
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string
|
||||
description: string
|
||||
params: ToolParameter[]
|
||||
}
|
||||
|
||||
export interface ToolExecutionResultEvent {
|
||||
toolName: string
|
||||
params: Record<string, unknown>
|
||||
result?: string
|
||||
error?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Server Events Extension
|
||||
// ============================================================
|
||||
|
||||
// ... (previous events)
|
||||
|
||||
export type ServerEvent
|
||||
= | { type: 'log', payload: LogEvent }
|
||||
| { type: 'llm', payload: LLMTraceEvent }
|
||||
@@ -112,6 +147,8 @@ export type ServerEvent
|
||||
| { type: 'trace_batch', payload: TraceBatchEvent }
|
||||
| { type: 'history', payload: ServerEvent[] }
|
||||
| { type: 'pong', payload: { timestamp: number } }
|
||||
| { type: 'debug:tools_list', payload: { tools: ToolDefinition[] } }
|
||||
| { type: 'debug:tool_result', payload: ToolExecutionResultEvent }
|
||||
|
||||
// ============================================================
|
||||
// Client -> Server commands
|
||||
@@ -146,13 +183,30 @@ export interface RequestHistoryCommand {
|
||||
type: 'request_history'
|
||||
}
|
||||
|
||||
// Union type for all client commands
|
||||
// ============================================================
|
||||
// Client Commands Extension
|
||||
// ============================================================
|
||||
|
||||
export interface ExecuteToolCommand {
|
||||
type: 'execute_tool'
|
||||
payload: {
|
||||
toolName: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestToolsCommand {
|
||||
type: 'request_tools'
|
||||
}
|
||||
|
||||
export type ClientCommand
|
||||
= | ClearLogsCommand
|
||||
| SetFilterCommand
|
||||
| InjectEventCommand
|
||||
| PingCommand
|
||||
| RequestHistoryCommand
|
||||
| ExecuteToolCommand
|
||||
| RequestToolsCommand
|
||||
|
||||
// ============================================================
|
||||
// Wire format
|
||||
|
||||
@@ -679,9 +679,240 @@ class SaliencyPanel {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Timeline Panel (Event Tracing)
|
||||
// Tools Panel
|
||||
// =============================================================================
|
||||
|
||||
class ToolsPanel {
|
||||
constructor(client) {
|
||||
this.client = client
|
||||
this.tools = []
|
||||
this.filter = ''
|
||||
this.executingTools = new Set()
|
||||
this.elements = {
|
||||
grid: document.getElementById('tools-grid'),
|
||||
search: document.getElementById('tools-search'),
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.client.on('debug:tools_list', data => this.updateTools(data))
|
||||
this.client.on('debug:tool_result', data => this.handleResult(data))
|
||||
this.client.on('connected', () => this.requestTools())
|
||||
|
||||
this.elements.search?.addEventListener('input', (e) => {
|
||||
this.filter = e.target.value.toLowerCase()
|
||||
this.render()
|
||||
})
|
||||
}
|
||||
|
||||
requestTools() {
|
||||
console.log('[ToolsPanel] Requesting tools...')
|
||||
// Check if we already have tools to avoid re-rendering on reconnect if not needed
|
||||
// But re-requesting ensures we are in sync with server capabilities
|
||||
this.client.send({ type: 'request_tools' })
|
||||
}
|
||||
|
||||
updateTools(data) {
|
||||
if (data && data.tools) {
|
||||
this.tools = data.tools
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.tools || this.tools.length === 0) {
|
||||
this.elements.grid.innerHTML = '<div class="empty-state">Loading tools...</div>'
|
||||
return
|
||||
}
|
||||
|
||||
const filtered = this.tools.filter((tool) => {
|
||||
if (!this.filter)
|
||||
return true
|
||||
return tool.name.toLowerCase().includes(this.filter)
|
||||
|| (tool.description && tool.description.toLowerCase().includes(this.filter))
|
||||
})
|
||||
|
||||
if (filtered.length === 0) {
|
||||
this.elements.grid.innerHTML = '<div class="empty-state">No tools match filter</div>'
|
||||
return
|
||||
}
|
||||
|
||||
// Don't nuke usage of existing DOM elements if possible to preserve form state?
|
||||
// For simplicity, re-render is fine for this debug tool.
|
||||
|
||||
this.elements.grid.innerHTML = filtered.map(tool => this.renderCard(tool)).join('')
|
||||
|
||||
// Attach event listeners
|
||||
filtered.forEach((tool) => {
|
||||
const card = document.getElementById(`tool-card-${tool.name}`)
|
||||
const executeBtn = card?.querySelector('.btn-execute')
|
||||
|
||||
executeBtn?.addEventListener('click', () => this.executeTool(tool))
|
||||
})
|
||||
}
|
||||
|
||||
renderCard(tool) {
|
||||
const isExecuting = this.executingTools.has(tool.name)
|
||||
const cardState = isExecuting ? 'executing' : ''
|
||||
const paramCount = tool.params.length
|
||||
|
||||
return `
|
||||
<div id="tool-card-${tool.name}" class="tool-card ${cardState}">
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-name">${escapeHtml(tool.name)}</span>
|
||||
${paramCount > 0 ? `<span class="tool-badge">${paramCount} param${paramCount > 1 ? 's' : ''}</span>` : ''}
|
||||
</div>
|
||||
<div class="tool-description">${escapeHtml(tool.description || '')}</div>
|
||||
${this.renderParams(tool)}
|
||||
<div class="tool-actions">
|
||||
<button class="btn-execute" ${isExecuting ? 'disabled' : ''}>
|
||||
${isExecuting ? 'Executing...' : 'Execute'}
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-${tool.name}" class="tool-result hidden"></div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
renderParams(tool) {
|
||||
if (tool.params.length === 0)
|
||||
return ''
|
||||
|
||||
return `
|
||||
<div class="tool-params">
|
||||
${tool.params.map(param => `
|
||||
<div class="param-group">
|
||||
<label class="param-label">${escapeHtml(param.name)} (${param.type})</label>
|
||||
<input
|
||||
type="${param.type === 'number' ? 'number' : 'text'}"
|
||||
class="param-input"
|
||||
data-param="${param.name}"
|
||||
${param.min !== undefined ? `min="${param.min}"` : ''}
|
||||
${param.max !== undefined ? `max="${param.max}"` : ''}
|
||||
${param.default !== undefined ? `value="${param.default}"` : ''}
|
||||
placeholder="${escapeHtml(param.description || '')}"
|
||||
/>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
executeTool(tool) {
|
||||
const card = document.getElementById(`tool-card-${tool.name}`)
|
||||
if (!card)
|
||||
return
|
||||
|
||||
// Collect parameter values
|
||||
const params = {}
|
||||
const inputs = card.querySelectorAll('.param-input')
|
||||
|
||||
for (const input of inputs) {
|
||||
const paramName = input.dataset.param
|
||||
let value = input.value
|
||||
|
||||
// Convert to appropriate type based on definition
|
||||
const paramDef = tool.params.find(p => p.name === paramName)
|
||||
if (paramDef) {
|
||||
if (paramDef.type === 'number') {
|
||||
if (value === '') {
|
||||
// Handle empty number input if needed?
|
||||
}
|
||||
else {
|
||||
value = Number.parseFloat(value)
|
||||
if (isNaN(value)) {
|
||||
this.showResult(tool.name, { error: `Invalid number for ${paramName}` }, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple type conversion could be improved but sufficient for now
|
||||
params[paramName] = value
|
||||
}
|
||||
|
||||
// Mark as executing
|
||||
this.executingTools.add(tool.name)
|
||||
this.updateCardState(tool.name, 'executing')
|
||||
this.hideResult(tool.name)
|
||||
|
||||
// Send command to server
|
||||
this.client.send({
|
||||
type: 'execute_tool',
|
||||
payload: {
|
||||
toolName: tool.name,
|
||||
params,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
handleResult(data) {
|
||||
const { toolName, result, error } = data
|
||||
|
||||
this.executingTools.delete(toolName)
|
||||
this.updateCardState(toolName, error ? 'error' : 'success')
|
||||
this.showResult(toolName, data, !!error)
|
||||
|
||||
if (!error) {
|
||||
setTimeout(() => {
|
||||
// Reset state visual but keep result visible for a bit?
|
||||
// Or remove success styling
|
||||
const card = document.getElementById(`tool-card-${toolName}`)
|
||||
if (card) {
|
||||
card.classList.remove('success')
|
||||
// Don't auto-hide result immediately, maybe user wants to read it
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
updateCardState(toolName, state) {
|
||||
const card = document.getElementById(`tool-card-${toolName}`)
|
||||
if (!card)
|
||||
return
|
||||
|
||||
card.classList.remove('executing', 'success', 'error')
|
||||
if (state)
|
||||
card.classList.add(state)
|
||||
|
||||
const btn = card.querySelector('.btn-execute')
|
||||
if (btn) {
|
||||
if (state === 'executing') {
|
||||
btn.disabled = true
|
||||
btn.textContent = 'Executing...'
|
||||
}
|
||||
else {
|
||||
btn.disabled = false
|
||||
btn.textContent = 'Execute'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showResult(toolName, data, isError) {
|
||||
const resultEl = document.getElementById(`result-${toolName}`)
|
||||
if (!resultEl)
|
||||
return
|
||||
|
||||
resultEl.classList.remove('hidden')
|
||||
|
||||
const label = isError ? 'Error' : 'Result'
|
||||
const content = isError ? data.error : data.result
|
||||
|
||||
resultEl.innerHTML = `
|
||||
<span class="result-label">${label}</span>
|
||||
<div class="result-content">${escapeHtml(content || 'No result')}</div>
|
||||
`
|
||||
}
|
||||
|
||||
hideResult(toolName) {
|
||||
const resultEl = document.getElementById(`result-${toolName}`)
|
||||
if (resultEl) {
|
||||
resultEl.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TimelinePanel {
|
||||
constructor(client) {
|
||||
this.client = client
|
||||
@@ -991,6 +1222,7 @@ class DebugApp {
|
||||
this.llmPanel = new LLMPanel(this.client)
|
||||
this.saliencyPanel = new SaliencyPanel(this.client)
|
||||
this.timelinePanel = new TimelinePanel(this.client)
|
||||
this.toolsPanel = new ToolsPanel(this.client)
|
||||
|
||||
this.panels = {
|
||||
queue: this.queuePanel,
|
||||
@@ -1000,6 +1232,7 @@ class DebugApp {
|
||||
llm: this.llmPanel,
|
||||
saliency: this.saliencyPanel,
|
||||
timeline: this.timelinePanel,
|
||||
tools: this.toolsPanel,
|
||||
}
|
||||
this.paused = false
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Dashboard</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="tools.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -108,6 +109,20 @@
|
||||
<pre id="blackboard-json" class="json-display">{}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tools Panel -->
|
||||
<section id="tools-section" class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Tools</h2>
|
||||
<div class="panel-controls">
|
||||
<input type="search" id="tools-search" placeholder="Filter tools..." />
|
||||
<button class="icon-btn maximize-btn" title="Toggle maximize">⤢</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div id="tools-grid" class="tools-grid"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Splitter V1 (Left vs Logs/Timeline/etc) -->
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/* Tools Panel */
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.tool-card.executing {
|
||||
border-color: var(--accent);
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.tool-card.success {
|
||||
border-color: var(--success);
|
||||
background: rgba(46, 160, 67, 0.05);
|
||||
}
|
||||
|
||||
.tool-card.error {
|
||||
border-color: var(--error);
|
||||
background: rgba(248, 81, 73, 0.05);
|
||||
}
|
||||
|
||||
.tool-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tool-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.tool-params {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.param-group {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.param-input {
|
||||
width: 100%;
|
||||
padding: 5px 7px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.param-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.param-description {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tool-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-execute {
|
||||
flex: 1;
|
||||
padding: 7px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-execute:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-execute:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tool-result {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-result.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -48,6 +48,10 @@ async function main() {
|
||||
const agent = await createNeuriAgent(bot)
|
||||
await bot.loadPlugin(CognitiveEngine({ agent, airiClient }))
|
||||
|
||||
// Setup Tool Executor for Debug Dashboard
|
||||
const { setupToolExecutor } = await import('./debug/tool-executor')
|
||||
setupToolExecutor(bot)
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
bot.stop()
|
||||
exit(0)
|
||||
|
||||
Reference in New Issue
Block a user