Merge pull request #160 from phj1081/codex/owner/ejclaw
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,
|
prepareReadonlySessionEnvironment,
|
||||||
prepareGroupEnvironment,
|
prepareGroupEnvironment,
|
||||||
} from './agent-runner-environment.js';
|
} from './agent-runner-environment.js';
|
||||||
|
import {
|
||||||
|
preparePromptIngestion,
|
||||||
|
recordPromptIngestion,
|
||||||
|
} from './agent-prompt-ingestion.js';
|
||||||
import { runSpawnedAgentProcess } from './agent-runner-process.js';
|
import { runSpawnedAgentProcess } from './agent-runner-process.js';
|
||||||
import { getStoredRoomSkillOverrides } from './db.js';
|
import { getStoredRoomSkillOverrides } from './db.js';
|
||||||
export {
|
export {
|
||||||
@@ -164,6 +168,19 @@ export async function runAgentProcess(
|
|||||||
'Spawning agent process',
|
'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');
|
const logsDir = path.join(groupDir, 'logs');
|
||||||
fs.mkdirSync(logsDir, { recursive: true });
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
|
||||||
@@ -176,10 +193,6 @@ export async function runAgentProcess(
|
|||||||
|
|
||||||
onProcess(proc, processName, env.EJCLAW_IPC_DIR);
|
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.write(JSON.stringify(runnerInput));
|
||||||
proc.stdin.end();
|
proc.stdin.end();
|
||||||
|
|
||||||
@@ -191,6 +204,11 @@ export async function runAgentProcess(
|
|||||||
logsDir,
|
logsDir,
|
||||||
startTime,
|
startTime,
|
||||||
onOutput,
|
onOutput,
|
||||||
}).then(resolve);
|
}).then((result) => {
|
||||||
|
if (result.status === 'success') {
|
||||||
|
recordPromptIngestion(promptIngestion);
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user