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);
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user