diff --git a/src/config/io.ts b/src/config/io.ts index 29518d9..94ee871 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -548,6 +548,21 @@ export function configToEnv(config: LettaBotConfig): Record { env.SLEEPTIME_STEP_COUNT = String(config.features.sleeptime.stepCount); } } + if (config.features?.conscience) { + if (config.features.conscience.agent) { + env.CONSCIENCE_AGENT_ID = config.features.conscience.agent; + } + if (config.features.conscience.conversation) { + // Only set as initial value — runtime resets mutate process.env directly + env.CONSCIENCE_CONVERSATION_ID ??= config.features.conscience.conversation; + } + if (config.features.conscience.trigger) { + env.CONSCIENCE_TRIGGER = config.features.conscience.trigger; + } + if (config.features.conscience.stepCount !== undefined) { + env.CONSCIENCE_STEP_COUNT = String(config.features.conscience.stepCount); + } + } if (config.features?.inlineImages === false) { env.INLINE_IMAGES = 'false'; } diff --git a/src/config/types.ts b/src/config/types.ts index fc5d0f1..a642a24 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -52,6 +52,19 @@ export interface SleeptimeConfig { stepCount?: number; } +export type ConscienceTrigger = 'off' | 'step-count'; + +export interface ConscienceConfig { + /** Letta agent ID for the persistent conscience agent (Aster) */ + agent?: string; + /** Initial conversation ID (runtime value tracked in CONSCIENCE_CONVERSATION_ID) */ + conversation?: string; + /** When to fire: 'step-count' fires every N turns, 'off' disables */ + trigger?: ConscienceTrigger; + /** Fire conscience every N user turns (independent of sleeptime cadence) */ + stepCount?: number; +} + /** * Configuration for a single agent in multi-agent mode. * Each agent has its own name, channels, and features. @@ -103,6 +116,7 @@ export interface AgentConfig { }; memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent) + conscience?: ConscienceConfig; // Persistent supervisory agent (Aster) — fires independently of sleeptime maxToolCalls?: number; sendFileDir?: string; // Restrict directive to this directory (default: data/outbound) sendFileMaxSize?: number; // Max file size in bytes for (default: 50MB) @@ -203,6 +217,7 @@ export interface LettaBotConfig { inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths. memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent) + conscience?: ConscienceConfig; // Persistent supervisory agent (Aster) — fires independently of sleeptime maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) sendFileDir?: string; // Restrict directive to this directory (default: data/outbound) sendFileMaxSize?: number; // Max file size in bytes for (default: 50MB) @@ -973,6 +988,29 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { }; } + // Conscience (Aster) — env vars override YAML, YAML sets initial values + const conscienceAgentId = process.env.CONSCIENCE_AGENT_ID; + const conscienceConversationId = process.env.CONSCIENCE_CONVERSATION_ID; + const conscienceStepCountRaw = process.env.CONSCIENCE_STEP_COUNT; + const conscienceTriggerRaw = process.env.CONSCIENCE_TRIGGER; + const conscienceTrigger = conscienceTriggerRaw === 'off' || conscienceTriggerRaw === 'step-count' + ? conscienceTriggerRaw as ConscienceTrigger + : undefined; + const conscienceStepCountParsed = conscienceStepCountRaw ? parseInt(conscienceStepCountRaw, 10) : undefined; + const conscienceStepCount = Number.isFinite(conscienceStepCountParsed) && (conscienceStepCountParsed as number) > 0 + ? conscienceStepCountParsed + : undefined; + + if (conscienceAgentId || conscienceConversationId || conscienceStepCount || conscienceTrigger) { + features.conscience = { + ...features.conscience, + ...(conscienceAgentId ? { agent: conscienceAgentId } : {}), + ...(conscienceConversationId ? { conversation: conscienceConversationId } : {}), + ...(conscienceTrigger ? { trigger: conscienceTrigger } : {}), + ...(conscienceStepCount ? { stepCount: conscienceStepCount } : {}), + }; + } + // Only pass features if there's actually something set const hasFeatures = Object.keys(features).length > 0; diff --git a/src/core/bot.ts b/src/core/bot.ts index f5ea2c7..91e799d 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -16,7 +16,7 @@ import { formatApiErrorForUser } from './errors.js'; import { formatToolCallDisplay, formatReasoningDisplay, formatQuestionsForChannel } from './display.js'; import type { AgentSession } from './interfaces.js'; import { Store } from './store.js'; -import { getPendingApprovals, rejectApproval, cancelRuns, cancelConversation, recoverOrphanedConversationApproval, getLatestRunError, getAgentModel, updateAgentModel, isRecoverableConversationId, recoverPendingApprovalsForAgent, createConversationForAgent } from '../tools/letta-api.js'; +import { getPendingApprovals, rejectApproval, cancelRuns, cancelConversation, recoverOrphanedConversationApproval, getLatestRunError, getAgentModel, updateAgentModel, isRecoverableConversationId, recoverPendingApprovalsForAgent, createConversationForAgent, sendMessageToConversation } from '../tools/letta-api.js'; import { getAgentSkillExecutableDirs, isVoiceMemoConfigured } from '../skills/loader.js'; import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js'; import type { GroupBatcher } from './group-batcher.js'; @@ -2067,6 +2067,13 @@ export class LettaBot implements AgentSession { adapter.addReaction?.(msg.chatId, msg.messageId, '✅').catch(() => {}); } + // Fire conscience agent (Aster) if configured — independent of sleeptime + this.triggerConscience({ + source: 'message', + seq, + label: `turn ${seq} | user:${msg.userId} | ${msg.channel}:${msg.chatId}`, + }).catch(err => log.warn('triggerConscience unexpected error:', err)); + } catch (error) { log.error('Error processing message:', error); if (!suppressDelivery && msg.messageId) { @@ -2320,6 +2327,13 @@ export class LettaBot implements AgentSession { log.warn(`Silent mode: agent produced ${response.length} chars but did NOT use lettabot-message CLI or directives — response discarded. If this keeps happening, the agent's model may not be following silent mode instructions.`); } } + + // Fire conscience agent (Aster) after every heartbeat turn + this.triggerConscience({ + source: 'heartbeat', + label: `heartbeat type=${context?.type ?? 'heartbeat'}`, + }).catch(err => log.warn('triggerConscience unexpected error:', err)); + return response; } catch (error) { // Invalidate on stream errors so next call gets a fresh subprocess @@ -2472,4 +2486,51 @@ export class LettaBot implements AgentSession { getLastUserMessageTime(): Date | null { return this.lastUserMessageTime; } + + // ========================================================================= + // Conscience (Aster) trigger + // ========================================================================= + + /** + * Fire the persistent conscience agent (Aster) if configured. + * For 'message' source: respects stepCount cadence. + * For 'heartbeat' source: fires unconditionally (every heartbeat). + * Runs fire-and-forget — errors are logged but never propagate to the caller. + */ + private async triggerConscience(opts: { + source: 'message' | 'heartbeat'; + seq?: number; + label?: string; + }): Promise { + const conscience = this.config.conscience; + if (!conscience || conscience.trigger === 'off') return; + + // User message path: respect stepCount cadence + if (opts.source === 'message') { + const stepCount = conscience.stepCount ?? 1; + if ((opts.seq ?? 0) % stepCount !== 0) return; + } + // Heartbeat path: always fires + + // Agent ID is static (from config / CONSCIENCE_AGENT_ID env var). + // Conversation ID is dynamic — mutated by /reset aster at runtime. + const agentId = conscience.agent || process.env.CONSCIENCE_AGENT_ID; + const conversationId = process.env.CONSCIENCE_CONVERSATION_ID || conscience.conversation; + + if (!agentId || !conversationId) { + log.warn('triggerConscience: no agent ID or conversation ID — skipping'); + return; + } + + const label = opts.label ?? opts.source; + const prompt = `[Conscience audit — ${label}]`; + + log.info(`triggerConscience: firing Aster (source=${opts.source}, seq=${opts.seq ?? 'n/a'}, conv=${conversationId})`); + try { + await sendMessageToConversation(conversationId, prompt); + log.info(`triggerConscience: Aster audit complete (source=${opts.source})`); + } catch (err) { + log.warn('triggerConscience: failed to trigger Aster (non-fatal):', err); + } + } } diff --git a/src/core/types.ts b/src/core/types.ts index 08fdd92..dfa6d43 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -156,8 +156,8 @@ export interface SkillsConfig { additionalSkills?: string[]; } -import type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig } from '../config/types.js'; -export type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig }; +import type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig, ConscienceTrigger, ConscienceConfig } from '../config/types.js'; +export type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig, ConscienceTrigger, ConscienceConfig }; /** * Bot configuration @@ -187,6 +187,7 @@ export interface BotConfig { // Memory filesystem (context repository) memfs?: boolean; // true -> --memfs, false -> --no-memfs, undefined -> leave unchanged sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent) + conscience?: ConscienceConfig; // Persistent supervisory agent (Aster) // Security redaction?: import('./redact.js').RedactionConfig; diff --git a/src/main.ts b/src/main.ts index 545451f..9726e06 100644 --- a/src/main.ts +++ b/src/main.ts @@ -385,6 +385,7 @@ async function main() { sendFileCleanup: agentConfig.features?.sendFileCleanup, memfs: resolvedMemfs, sleeptime: effectiveSleeptime, + conscience: agentConfig.features?.conscience, display: agentConfig.features?.display, conversationMode: agentConfig.conversations?.mode || 'shared', heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active', @@ -630,6 +631,7 @@ async function main() { stores: agentStores, agentChannels: agentChannelMap, sessionInvalidators, + heartbeatServices: services.heartbeatServices, }); // Startup banner diff --git a/src/tools/letta-api.ts b/src/tools/letta-api.ts index 32640ea..010ec61 100644 --- a/src/tools/letta-api.ts +++ b/src/tools/letta-api.ts @@ -1008,3 +1008,18 @@ export async function createConversationForAgent(agentId: string): Promise { + const client = getClient(); + await client.conversations.messages.create(conversationId, { + input: text, + streaming: false, + } as Parameters[1]); +}