fix: bootstrap role prompts into resumed sessions
This commit is contained in:
127
src/agent-prompt-ingestion.test.ts
Normal file
127
src/agent-prompt-ingestion.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
132
src/agent-prompt-ingestion.ts
Normal file
132
src/agent-prompt-ingestion.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user