fix: bootstrap role prompts into resumed sessions

This commit is contained in:
ejclaw
2026-05-25 21:12:03 +09:00
parent 3595a37f1f
commit 48d58a3324
3 changed files with 282 additions and 5 deletions

View File

@@ -0,0 +1,127 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
preparePromptIngestion,
recordPromptIngestion,
} from './agent-prompt-ingestion.js';
describe('agent prompt ingestion', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync('/tmp/ejclaw-prompt-ingestion-');
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
function writeClaudePrompt(text: string): string {
const claudeDir = path.join(tempDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const promptPath = path.join(claudeDir, 'CLAUDE.md');
fs.writeFileSync(promptPath, text);
return claudeDir;
}
function writeCodexPrompt(text: string): string {
const codexDir = path.join(tempDir, '.codex');
fs.mkdirSync(codexDir, { recursive: true });
const promptPath = path.join(codexDir, 'AGENTS.md');
fs.writeFileSync(promptPath, text);
return codexDir;
}
it('injects the role prompt when starting a fresh Claude session', () => {
const claudeDir = writeClaudePrompt('platform rules\n\npaired rules\n');
const plan = preparePromptIngestion({
agentType: 'claude-code',
env: { CLAUDE_CONFIG_DIR: claudeDir },
prompt: 'review current changes',
});
expect(plan.prompt).toContain('<ejclaw_role_prompt>');
expect(plan.prompt).toContain('platform rules');
expect(plan.prompt).toContain('paired rules');
expect(plan.prompt).toContain('Current task:\nreview current changes');
});
it('does not reinject an unchanged prompt into an existing session', () => {
const codexDir = writeCodexPrompt('codex platform rules\n');
const first = preparePromptIngestion({
agentType: 'codex',
env: { CODEX_HOME: codexDir },
prompt: 'first task',
});
recordPromptIngestion(first);
const second = preparePromptIngestion({
agentType: 'codex',
env: { CODEX_HOME: codexDir },
prompt: 'second task',
sessionId: 'session-existing',
});
expect(second.prompt).toBe('second task');
});
it('reinjects when the prompt pack changes in an existing session', () => {
const claudeDir = writeClaudePrompt('old rules\n');
const first = preparePromptIngestion({
agentType: 'claude-code',
env: { CLAUDE_CONFIG_DIR: claudeDir },
prompt: 'first task',
sessionId: 'session-existing',
});
recordPromptIngestion(first);
fs.writeFileSync(path.join(claudeDir, 'CLAUDE.md'), 'new rules\n');
const second = preparePromptIngestion({
agentType: 'claude-code',
env: { CLAUDE_CONFIG_DIR: claudeDir },
prompt: 'second task',
sessionId: 'session-existing',
});
expect(second.prompt).toContain('new rules');
expect(second.prompt).toContain('Current task:\nsecond task');
});
it('does not treat a one-time memory briefing as a prompt pack change', () => {
const claudeDir = writeClaudePrompt(
'stable rules\n\n---\n\n## Shared Room Memory\n- remembered',
);
const first = preparePromptIngestion({
agentType: 'claude-code',
env: { CLAUDE_CONFIG_DIR: claudeDir },
prompt: 'first task',
memoryBriefing: '## Shared Room Memory\n- remembered',
});
recordPromptIngestion(first);
fs.writeFileSync(path.join(claudeDir, 'CLAUDE.md'), 'stable rules\n');
const second = preparePromptIngestion({
agentType: 'claude-code',
env: { CLAUDE_CONFIG_DIR: claudeDir },
prompt: 'second task',
sessionId: 'session-existing',
});
expect(second.prompt).toBe('second task');
});
it('does not inject role prompts into compact commands', () => {
const claudeDir = writeClaudePrompt('platform rules\n');
const plan = preparePromptIngestion({
agentType: 'claude-code',
env: { CLAUDE_CONFIG_DIR: claudeDir },
prompt: '/compact',
});
expect(plan.prompt).toBe('/compact');
});
});

View File

@@ -0,0 +1,132 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import type { AgentType } from './types.js';
const PROMPT_INGESTION_STATE_FILE = '.ejclaw-prompt-ingestion.json';
export interface PromptIngestionPlan {
prompt: string;
fingerprint?: string;
statePath?: string;
}
function hashPrompt(prompt: string): string {
return crypto.createHash('sha256').update(prompt).digest('hex');
}
function readStoredFingerprint(statePath: string): string | undefined {
try {
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
fingerprint?: unknown;
};
return typeof parsed.fingerprint === 'string'
? parsed.fingerprint
: undefined;
} catch {
return undefined;
}
}
function stripTrailingMemoryBriefing(
promptContext: string,
memoryBriefing?: string,
): string {
if (!memoryBriefing) {
return promptContext;
}
const suffix = `\n\n---\n\n${memoryBriefing}`;
return promptContext.endsWith(suffix)
? promptContext.slice(0, -suffix.length)
: promptContext;
}
function resolvePromptContextPath(args: {
agentType: AgentType;
env: Record<string, string>;
}): string | undefined {
if (args.agentType === 'codex' && args.env.CODEX_HOME) {
return path.join(args.env.CODEX_HOME, 'AGENTS.md');
}
if (args.env.CLAUDE_CONFIG_DIR) {
return path.join(args.env.CLAUDE_CONFIG_DIR, 'CLAUDE.md');
}
if (args.env.CODEX_HOME) {
return path.join(args.env.CODEX_HOME, 'AGENTS.md');
}
return undefined;
}
function buildPromptBootstrap(promptContext: string, prompt: string): string {
return `System bootstrap:
Load and follow the current EJClaw role prompt below. This bootstrap is injected only when a role session starts or when the prompt pack changes. Do not summarize it; apply it silently to the task that follows.
<ejclaw_role_prompt>
${promptContext}
</ejclaw_role_prompt>
Current task:
${prompt}`;
}
export function preparePromptIngestion(args: {
agentType: AgentType;
env: Record<string, string>;
prompt: string;
sessionId?: string;
memoryBriefing?: string;
}): PromptIngestionPlan {
if (args.prompt.trim() === '/compact') {
return { prompt: args.prompt };
}
const promptContextPath = resolvePromptContextPath(args);
if (!promptContextPath || !fs.existsSync(promptContextPath)) {
return { prompt: args.prompt };
}
const promptContext = fs.readFileSync(promptContextPath, 'utf-8').trim();
if (!promptContext) {
return { prompt: args.prompt };
}
const fingerprintSource = stripTrailingMemoryBriefing(
promptContext,
args.memoryBriefing,
).trim();
const fingerprint = hashPrompt(fingerprintSource || promptContext);
const statePath = path.join(
path.dirname(promptContextPath),
PROMPT_INGESTION_STATE_FILE,
);
const storedFingerprint = readStoredFingerprint(statePath);
if (args.sessionId && storedFingerprint === fingerprint) {
return { prompt: args.prompt, fingerprint, statePath };
}
return {
prompt: buildPromptBootstrap(promptContext, args.prompt),
fingerprint,
statePath,
};
}
export function recordPromptIngestion(plan: PromptIngestionPlan): void {
if (!plan.fingerprint || !plan.statePath) {
return;
}
fs.mkdirSync(path.dirname(plan.statePath), { recursive: true });
fs.writeFileSync(
plan.statePath,
JSON.stringify(
{
fingerprint: plan.fingerprint,
ingested_at: new Date().toISOString(),
},
null,
2,
) + '\n',
);
}

View File

@@ -12,6 +12,10 @@ import {
prepareReadonlySessionEnvironment,
prepareGroupEnvironment,
} from './agent-runner-environment.js';
import {
preparePromptIngestion,
recordPromptIngestion,
} from './agent-prompt-ingestion.js';
import { runSpawnedAgentProcess } from './agent-runner-process.js';
import { getStoredRoomSkillOverrides } from './db.js';
export {
@@ -164,6 +168,19 @@ export async function runAgentProcess(
'Spawning agent process',
);
const runnerInput: AgentInput = {
...input,
...(group.agentConfig?.codexGoals === true ? { codexGoals: true } : {}),
};
const promptIngestion = preparePromptIngestion({
agentType: group.agentType || 'claude-code',
env,
prompt: runnerInput.prompt,
sessionId: runnerInput.sessionId,
memoryBriefing: runnerInput.memoryBriefing,
});
runnerInput.prompt = promptIngestion.prompt;
const logsDir = path.join(groupDir, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
@@ -176,10 +193,6 @@ export async function runAgentProcess(
onProcess(proc, processName, env.EJCLAW_IPC_DIR);
const runnerInput: AgentInput = {
...input,
...(group.agentConfig?.codexGoals === true ? { codexGoals: true } : {}),
};
proc.stdin.write(JSON.stringify(runnerInput));
proc.stdin.end();
@@ -191,6 +204,11 @@ export async function runAgentProcess(
logsDir,
startTime,
onOutput,
}).then(resolve);
}).then((result) => {
if (result.status === 'success') {
recordPromptIngestion(promptIngestion);
}
resolve(result);
});
});
}