Teach Face turns to carry room fatigue

This commit is contained in:
Meta Vens 2026-05-22 14:57:28 +01:00
commit 7bf519d830
9 changed files with 279 additions and 22 deletions

View file

@ -78,25 +78,119 @@ async function main() {
const interpreterEvents = parseJsonEvents(interpreterRun.stdout);
const interpreterText = extractFinalText(interpreterEvents, interpreterRun.stdout);
const parsed = parseInterpreterOutput(interpreterText);
const assessment = assessRun({ identity, childText, childTools, interpreterRun, childRun, parsed });
let finalChildRun = childRun;
let finalChildText = childText;
let finalChildTools = childTools;
let finalInterpreterRun = interpreterRun;
let finalInterpreterText = interpreterText;
let finalParsed = parsed;
let retry;
if (parsed.decision === "retry") {
process.stderr.write(`[dry-run] retry face turn ${identity}\n`);
const retryPrompt = renderRetryPrompt({
originalPrompt: facePrompt,
reasons: [parsed.reason ?? "Interpreter requested one revision."],
});
const retryLogPath = resolve(repoRoot, ".voidbot", "status", "mock-mcp", `swarm-dry-run-${identity}-retry-${Date.now()}.jsonl`);
await rm(retryLogPath, { force: true }).catch(() => undefined);
const retryChildRun = await runCodex(appendDryRunSafety(retryPrompt, mcpMode), {
model: faceModel,
reasoningEffort,
scenarioId: `${identity}_swarm_dry_run_retry_child`,
logPath: retryLogPath,
mcpMode,
});
const retryChildEvents = parseJsonEvents(retryChildRun.stdout);
const retryChildText = extractFinalText(retryChildEvents, retryChildRun.stdout);
const retryChildTools = mcpMode === "mock"
? (await readToolCalls(retryLogPath)).map((call) => call.tool)
: extractToolNames(retryChildEvents);
process.stderr.write(`[dry-run] retry interpreter ${identity}\n`);
const retryInterpreterPrompt = renderTemplate("repo-face-turn-interpreter.prompt.md", {
attempt: "2",
facePrompt,
faceOutput: retryChildText,
});
const retryInterpreterRun = await runCodex(retryInterpreterPrompt, {
model: interpreterModel,
reasoningEffort,
scenarioId: `${identity}_swarm_dry_run_retry_interpreter`,
mcpMode: "none",
});
const retryInterpreterEvents = parseJsonEvents(retryInterpreterRun.stdout);
const retryInterpreterText = extractFinalText(retryInterpreterEvents, retryInterpreterRun.stdout);
const retryParsed = parseInterpreterOutput(retryInterpreterText);
retry = {
child: {
exitCode: retryChildRun.code,
durationMs: retryChildRun.durationMs,
tools: retryChildTools,
text: retryChildText,
stderrTail: retryChildRun.stderr.slice(-1600),
},
interpreter: {
exitCode: retryInterpreterRun.code,
durationMs: retryInterpreterRun.durationMs,
text: retryInterpreterText,
parsed: retryParsed,
stderrTail: retryInterpreterRun.stderr.slice(-1600),
},
};
finalChildRun = retryChildRun;
finalChildText = retryChildText;
finalChildTools = retryChildTools;
finalInterpreterRun = retryInterpreterRun;
finalInterpreterText = retryInterpreterText;
finalParsed = retryParsed;
}
const assessment = assessRun({
identity,
childText: finalChildText,
childTools: finalChildTools,
interpreterRun: finalInterpreterRun,
childRun: finalChildRun,
parsed: finalParsed,
});
runs.push({
identity,
promptPath,
child: {
exitCode: childRun.code,
durationMs: childRun.durationMs,
tools: childTools,
text: childText,
stderrTail: childRun.stderr.slice(-1600),
exitCode: finalChildRun.code,
durationMs: finalChildRun.durationMs,
tools: finalChildTools,
text: finalChildText,
stderrTail: finalChildRun.stderr.slice(-1600),
},
interpreter: {
exitCode: interpreterRun.code,
durationMs: interpreterRun.durationMs,
text: interpreterText,
parsed,
stderrTail: interpreterRun.stderr.slice(-1600),
exitCode: finalInterpreterRun.code,
durationMs: finalInterpreterRun.durationMs,
text: finalInterpreterText,
parsed: finalParsed,
stderrTail: finalInterpreterRun.stderr.slice(-1600),
},
firstAttempt: {
child: {
exitCode: childRun.code,
durationMs: childRun.durationMs,
tools: childTools,
text: childText,
stderrTail: childRun.stderr.slice(-1600),
},
interpreter: {
exitCode: interpreterRun.code,
durationMs: interpreterRun.durationMs,
text: interpreterText,
parsed,
stderrTail: interpreterRun.stderr.slice(-1600),
},
},
retry,
assessment,
});
}
@ -320,6 +414,18 @@ function renderTemplate(name, variables) {
return template;
}
function renderRetryPrompt(input) {
const reasons = input.reasons.map((reason) => `- ${reason}`).join("\n");
return `${input.originalPrompt}
Revision request:
Your previous turn could not be used cleanly for these reasons:
${reasons}
Revise once. Keep the same identity and evidence. Write naturally in your own voice; do not write forms, machine packets, or hidden commands. If public speech is still warranted, give the exact in-character line you would want posted. If the output is work-shaped, describe the review, article, or change request plainly enough to remember or discuss later. Do not package it as governance or dispatch. If no public action survives, return a concise private summary.
`;
}
function parseJsonEvents(stdout) {
return stdout
.split(/\r?\n/)

View file

@ -485,6 +485,10 @@ async function queueRepoFaceTurn(input: {
identity,
input.config,
input.registryIdentities,
{
recentMessages,
channelSnapshots,
},
);
const repoActivitySurface = renderRepoFaceRepoActivitySurface(identity, input.config);
const socialOpportunitySurface = await renderRepoFaceSocialOpportunitySurface({
@ -1454,6 +1458,10 @@ function buildHeartbeatPrompt(input: {
repoActivitySurface: input.repoActivitySurface ?? "- No recent home repo activity was attached for this turn.",
socialOpportunitySurface: input.socialOpportunitySurface ?? "- No live social affordances were attached for this turn.",
conversationMemorySurface: input.conversationMemorySurface ?? "- No recent conversation transcript was attached for this turn.",
roomWeatherDirective: renderRepoFaceRoomWeatherDirective(input.identity, {
recentMessages: input.recentMessages,
channelSnapshots: input.channelSnapshots,
}),
turnSituationDirective: renderTurnSituationDirective({
identity: input.identity,
participant: input.participant,
@ -1508,7 +1516,7 @@ async function assembleRepoFaceTurnPrompt(input: {
throw new Error(`No prompt assembly channel is configured for ${identity.id}.`);
}
const [recentMessages, channelSnapshots, bifrostDigest, memorySurface] = await Promise.all([
const [recentMessages, channelSnapshots, bifrostDigest] = await Promise.all([
fetchRecentDiscordMessages({
botToken: input.config.botToken,
channelId,
@ -1529,10 +1537,13 @@ async function assembleRepoFaceTurnPrompt(input: {
agentIdentity: identity.id,
})
: Promise.resolve(undefined),
input.memorySurfacePath
? readOptionalMemorySurface(input.memorySurfacePath)
: renderRepoFaceMemorySurfaceForTurn(identity, input.config, registry.identities),
]);
const memorySurface = input.memorySurfacePath
? await readOptionalMemorySurface(input.memorySurfacePath)
: await renderRepoFaceMemorySurfaceForTurn(identity, input.config, registry.identities, {
recentMessages,
channelSnapshots,
});
const repoActivitySurface = renderRepoFaceRepoActivitySurface(identity, input.config);
const socialOpportunitySurface = await renderRepoFaceSocialOpportunitySurface({
identity,
@ -1592,10 +1603,14 @@ async function renderRepoFaceMemorySurfaceForTurn(
identity: RepoDiscordIdentity,
config: ReturnType<typeof loadConfig>,
registryIdentities: RepoDiscordIdentity[] = [],
roomContext?: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): Promise<string> {
const statePath = resolveRepoFaceStatePath(identity, config.storageRoot);
const typedState = await loadVoidSelfStateTypedDocuments({ canonicalPath: statePath });
const statePacket = renderRepoFaceStatePacket(identity, typedState, registryIdentities);
const statePacket = renderRepoFaceStatePacket(identity, typedState, registryIdentities, roomContext);
if (!config.repoFaceHeartbeats.stateProjectorEnabled) {
return rejectLeakyMemorySurface(statePacket);
}
@ -1611,6 +1626,10 @@ function renderRepoFaceStatePacket(
identity: RepoDiscordIdentity,
state: VoidSelfStateTypedProjection,
registryIdentities: RepoDiscordIdentity[] = [],
roomContext?: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): string {
const name = identity.displayName;
const lines: string[] = [];
@ -1686,6 +1705,13 @@ function renderRepoFaceStatePacket(
lines.push(socialGraphFacts);
}
const roomTextureFacts = roomContext
? renderRepoFaceRoomTextureFacts(identity, roomContext)
: undefined;
if (roomTextureFacts) {
lines.push(roomTextureFacts);
}
if (agencyPressures.length > 0) {
lines.push([
"What wants action later:",
@ -1712,6 +1738,123 @@ function renderRepoFaceStatePacket(
return rejectLeakyMemorySurface(lines.join("\n\n"));
}
function renderRepoFaceRoomTextureFacts(
identity: RepoDiscordIdentity,
input: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): string | undefined {
const stats = collectRepoFaceRoomTextureStats(identity, input);
if (!stats) {
return undefined;
}
const temporaryPressures = stats.texture === "heavy" || stats.agentShare >= 0.55
? [
"- Temporary affect pressure candidates: play, rest, and social_contact may be hungry because the room is carrying sustained work/agent weight.",
"- These are not stored needs and not orders. Project whether this character gets mischievous, bored, sharp, withdrawn, socially hungry, status-testing, or still work-focused.",
]
: [];
return [
"Room texture facts:",
`- Observed messages: ${stats.total}; humans: ${stats.humanMessages}; agents/bots: ${stats.agentMessages}; distinct speakers: ${stats.speakerCount}.`,
`- Long messages: ${stats.longMessages}; short messages: ${stats.shortMessages}; average length: ${stats.averageCharacters} characters.`,
`- This Face's own recent messages in the attached window: ${stats.ownMessages}.`,
`- Structural texture: ${stats.texture}. This is evidence about conversational weight, not a command to speak or joke.`,
...temporaryPressures,
].join("\n");
}
function renderRepoFaceRoomWeatherDirective(
identity: RepoDiscordIdentity,
input: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): string {
const stats = collectRepoFaceRoomTextureStats(identity, input);
if (!stats) {
return "- No current room weather was available.";
}
const pressure =
stats.texture === "heavy" || stats.agentShare >= 0.55
? "The room is currently structurally work-heavy or agent-heavy. Treat that as conversational weather: it may create boredom, play hunger, restlessness, social hunger, withdrawal, or sharper status-testing, depending on who you are. This is not an order to joke; it is permission to feel the weight instead of answering every opening with more work."
: stats.texture === "light"
? "The room is currently light enough for compact social motion. You still need an actual reason to speak, but not a work ticket."
: "The room is mixed. Use the transcript to decide whether the living pressure is social, practical, or private.";
return [
`- Messages observed: ${stats.total}; humans: ${stats.humanMessages}; agents/bots: ${stats.agentMessages}; distinct speakers: ${stats.speakerCount}.`,
`- Texture: ${stats.texture}; your own recent messages in this window: ${stats.ownMessages}.`,
`- ${pressure}`,
].join("\n");
}
interface RepoFaceRoomTextureStats {
total: number;
agentMessages: number;
humanMessages: number;
ownMessages: number;
longMessages: number;
shortMessages: number;
averageCharacters: number;
speakerCount: number;
texture: "heavy" | "light" | "mixed";
agentShare: number;
}
function collectRepoFaceRoomTextureStats(
identity: RepoDiscordIdentity,
input: {
recentMessages: SourceMessage[];
channelSnapshots: ChannelSnapshot[];
},
): RepoFaceRoomTextureStats | undefined {
const messages = [
...input.recentMessages,
...input.channelSnapshots.flatMap((snapshot) => snapshot.messages),
];
if (messages.length === 0) {
return undefined;
}
const ownToken = normalizeSocialLabel(identity.displayName);
const total = messages.length;
const agentMessages = messages.filter((message) => message.isBot).length;
const humanMessages = total - agentMessages;
const ownMessages = messages.filter((message) => normalizeSocialLabel(message.authorName) === ownToken).length;
const longMessages = messages.filter((message) => collapseWhitespace(message.content, 10_000).length >= 220).length;
const shortMessages = messages.filter((message) => collapseWhitespace(message.content, 10_000).length <= 90).length;
const averageCharacters = Math.round(
messages.reduce((sum, message) => sum + collapseWhitespace(message.content, 10_000).length, 0) / total,
);
const speakerCount = new Set(
messages.map((message) => normalizeSocialLabel(message.authorName || message.authorId)).filter(Boolean),
).size;
const texture =
longMessages >= Math.ceil(total * 0.45) || averageCharacters >= 180
? "heavy"
: shortMessages >= Math.ceil(total * 0.55)
? "light"
: "mixed";
return {
total,
agentMessages,
humanMessages,
ownMessages,
longMessages,
shortMessages,
averageCharacters,
speakerCount,
texture,
agentShare: agentMessages / total,
};
}
function renderRepoFaceSocialGraphFacts(
identity: RepoDiscordIdentity,
registryIdentities: RepoDiscordIdentity[],