feat(conscience): Aster fires now — experimental supervisory trigger

Conscience is live as a first-class system, independent of sleeptime.
ConscienceConfig is a proper YAML block with its own trigger cadence —
sleeptime is disabled, Aster runs the supervisory layer.

User messages: Aster audits every N turns (stepCount, default 6).
Heartbeats: Aster fires unconditionally after every successful turn.
Opt-in by design — graceful no-op for agents that don't configure it.

Payload assembly (last-state.yaml, transcript context) is still on the
roadmap. This commit gets the trigger wired and the infrastructure solid.
This commit is contained in:
Ani Tunturi
2026-03-28 20:17:28 -04:00
parent 81ee845677
commit d0c163962e
6 changed files with 135 additions and 3 deletions

View File

@@ -548,6 +548,21 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
env.SLEEPTIME_STEP_COUNT = String(config.features.sleeptime.stepCount); 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) { if (config.features?.inlineImages === false) {
env.INLINE_IMAGES = 'false'; env.INLINE_IMAGES = 'false';
} }

View File

@@ -52,6 +52,19 @@ export interface SleeptimeConfig {
stepCount?: number; 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. * Configuration for a single agent in multi-agent mode.
* Each agent has its own name, channels, and features. * 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 memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent) sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent)
conscience?: ConscienceConfig; // Persistent supervisory agent (Aster) — fires independently of sleeptime
maxToolCalls?: number; maxToolCalls?: number;
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound) sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB) sendFileMaxSize?: number; // Max file size in bytes for <send-file> (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. 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 memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent) 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) maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100)
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound) sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB) sendFileMaxSize?: number; // Max file size in bytes for <send-file> (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 // Only pass features if there's actually something set
const hasFeatures = Object.keys(features).length > 0; const hasFeatures = Object.keys(features).length > 0;

View File

@@ -16,7 +16,7 @@ import { formatApiErrorForUser } from './errors.js';
import { formatToolCallDisplay, formatReasoningDisplay, formatQuestionsForChannel } from './display.js'; import { formatToolCallDisplay, formatReasoningDisplay, formatQuestionsForChannel } from './display.js';
import type { AgentSession } from './interfaces.js'; import type { AgentSession } from './interfaces.js';
import { Store } from './store.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 { getAgentSkillExecutableDirs, isVoiceMemoConfigured } from '../skills/loader.js';
import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js'; import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js';
import type { GroupBatcher } from './group-batcher.js'; import type { GroupBatcher } from './group-batcher.js';
@@ -2067,6 +2067,13 @@ export class LettaBot implements AgentSession {
adapter.addReaction?.(msg.chatId, msg.messageId, '✅').catch(() => {}); 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) { } catch (error) {
log.error('Error processing message:', error); log.error('Error processing message:', error);
if (!suppressDelivery && msg.messageId) { 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.`); 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; return response;
} catch (error) { } catch (error) {
// Invalidate on stream errors so next call gets a fresh subprocess // Invalidate on stream errors so next call gets a fresh subprocess
@@ -2472,4 +2486,51 @@ export class LettaBot implements AgentSession {
getLastUserMessageTime(): Date | null { getLastUserMessageTime(): Date | null {
return this.lastUserMessageTime; 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<void> {
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);
}
}
} }

View File

@@ -156,8 +156,8 @@ export interface SkillsConfig {
additionalSkills?: string[]; additionalSkills?: string[];
} }
import type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig } from '../config/types.js'; import type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig, ConscienceTrigger, ConscienceConfig } from '../config/types.js';
export type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig }; export type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig, ConscienceTrigger, ConscienceConfig };
/** /**
* Bot configuration * Bot configuration
@@ -187,6 +187,7 @@ export interface BotConfig {
// Memory filesystem (context repository) // Memory filesystem (context repository)
memfs?: boolean; // true -> --memfs, false -> --no-memfs, undefined -> leave unchanged memfs?: boolean; // true -> --memfs, false -> --no-memfs, undefined -> leave unchanged
sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent) sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent)
conscience?: ConscienceConfig; // Persistent supervisory agent (Aster)
// Security // Security
redaction?: import('./redact.js').RedactionConfig; redaction?: import('./redact.js').RedactionConfig;

View File

@@ -385,6 +385,7 @@ async function main() {
sendFileCleanup: agentConfig.features?.sendFileCleanup, sendFileCleanup: agentConfig.features?.sendFileCleanup,
memfs: resolvedMemfs, memfs: resolvedMemfs,
sleeptime: effectiveSleeptime, sleeptime: effectiveSleeptime,
conscience: agentConfig.features?.conscience,
display: agentConfig.features?.display, display: agentConfig.features?.display,
conversationMode: agentConfig.conversations?.mode || 'shared', conversationMode: agentConfig.conversations?.mode || 'shared',
heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active', heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active',
@@ -630,6 +631,7 @@ async function main() {
stores: agentStores, stores: agentStores,
agentChannels: agentChannelMap, agentChannels: agentChannelMap,
sessionInvalidators, sessionInvalidators,
heartbeatServices: services.heartbeatServices,
}); });
// Startup banner // Startup banner

View File

@@ -1008,3 +1008,18 @@ export async function createConversationForAgent(agentId: string): Promise<strin
return null; return null;
} }
} }
/**
* Send a fire-and-forget message to an existing conversation.
* Uses streaming=false so the call completes before returning.
*/
export async function sendMessageToConversation(
conversationId: string,
text: string,
): Promise<void> {
const client = getClient();
await client.conversations.messages.create(conversationId, {
input: text,
streaming: false,
} as Parameters<typeof client.conversations.messages.create>[1]);
}