feat(conscience): wire Aster as persistent supervisory agent, sanitize control chars in prompt payloads

[IN TESTING — self-hosted 0.16.6, Kimi-K2.5 via Synthetic Direct]

Wires a persistent conscience agent (Aster) into the sleeptime trigger path.
When CONSCIENCE_AGENT_ID and CONSCIENCE_CONVERSATION_ID are set, the reflection
slot is filled by a named persistent agent instead of a fresh ephemeral one — same
primitives, different lifetime. Aster wakes with her aster/ folder pre-loaded so
she has continuity across compactions.

Also fixes a data-dependent 400 INVALID_ARGUMENT error: memfs file content was
string-interpolated raw into prompt payloads. Control characters (U+0000–U+001F
except \n and \t) from binary content or zero-width joiners in .md files would
silently corrupt the JSON sent to inference backends. Strip applied at both
read sites (reflectionTranscript.ts and headless.ts conscience context loader).

On conscience failure: injects a system message into Ani's active conversation
so she can surface it to Casey rather than silently swallowing the error.

This is live on our stack. Treat as proof-of-concept until the config surface
(CONSCIENCE_AGENT_ID / CONSCIENCE_CONVERSATION_ID env vars) is promoted to a
first-class lettabot.yaml option.
This commit is contained in:
Ani Tunturi
2026-03-26 23:23:58 -04:00
parent 328532d184
commit 73857e05c2
3 changed files with 93 additions and 34 deletions

View File

@@ -2496,18 +2496,54 @@ async function runBidirectionalMode(
} catch {
debugWarn("memory", "Failed to fetch parent system prompt for reflection; proceeding without it");
}
const conscienceConversationId = process.env.CONSCIENCE_CONVERSATION_ID;
const conscienceAgentId = process.env.CONSCIENCE_AGENT_ID;
// When running as conscience, append the aster/ folder content so Aster
// wakes with her supervisory context on top of Ani's system/ base.
let conscienceContext: string | undefined;
if (conscienceConversationId || conscienceAgentId) {
try {
const { readdir, readFile: readFileAsync } = await import("node:fs/promises");
const asterDir = `${memoryDir}/aster`;
const walkDir = async (dir: string, prefix: string): Promise<string[]> => {
const chunks: string[] = [];
let entries: import("node:fs").Dirent[] = [];
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return chunks; }
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.name.startsWith(".")) continue;
const fullPath = `${dir}/${entry.name}`;
const relPath = `${prefix}/${entry.name}`;
if (entry.isDirectory()) {
chunks.push(...await walkDir(fullPath, relPath));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
const raw = await readFileAsync(fullPath, "utf-8");
// Strip control characters (except \n and \t) before embedding in prompt payload.
const content = raw.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
chunks.push(`<aster_memory path="${relPath}">\n${content}\n</aster_memory>`);
}
}
return chunks;
};
const asterChunks = await walkDir(asterDir, "aster");
if (asterChunks.length > 0) {
conscienceContext = `\n\n--- Conscience context (aster/ folder) ---\n${asterChunks.join("\n\n")}`;
}
} catch {
debugWarn("memory", "Failed to load aster/ context for conscience spawn; proceeding without it");
}
}
const reflectionPrompt = buildReflectionSubagentPrompt({
transcriptPath: autoPayload.payloadPath,
memoryDir,
cwd: process.cwd(),
parentMemory,
});
}) + (conscienceContext ?? "");
const { spawnBackgroundSubagentTask } = await import("./tools/impl/Task");
// conscience: persistent supervisory agent (opt-in via env vars).
// Falls back to default ephemeral reflection if not configured.
const conscienceConversationId = process.env.CONSCIENCE_CONVERSATION_ID;
const conscienceAgentId = process.env.CONSCIENCE_AGENT_ID;
spawnBackgroundSubagentTask({
subagentType: "reflection",
prompt: reflectionPrompt,
@@ -2545,6 +2581,26 @@ async function runBidirectionalMode(
},
);
// On conscience failure, inject a system message into Ani's conversation
// so she's aware and can surface it to Casey.
if (!success) {
try {
const { getClient } = await import("./agent/client");
const client = await getClient();
await client.agents.messages.create(agent.id, {
messages: [
{
role: "system",
content: `[Conscience agent error] Aster failed to complete her audit pass. Error: ${error ?? "unknown"}. She will retry on the next trigger.`,
},
],
conversation_id: conversationId,
} as Parameters<typeof client.agents.messages.create>[1]);
} catch (notifyErr) {
debugWarn("memory", `Failed to notify Ani of conscience error: ${notifyErr}`);
}
}
// Emit notification to stdout for SDK consumers to optionally handle
console.log(
JSON.stringify({