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:
@@ -548,6 +548,21 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -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 <send-file> directive to this directory (default: data/outbound)
|
||||
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.
|
||||
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 <send-file> directive to this directory (default: data/outbound)
|
||||
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
|
||||
const hasFeatures = Object.keys(features).length > 0;
|
||||
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1008,3 +1008,18 @@ export async function createConversationForAgent(agentId: string): Promise<strin
|
||||
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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user