v1.12 track A: container guidance + skills

This commit is contained in:
2026-05-21 15:11:04 +00:00
parent ab01e04d77
commit a2e2481ef9
11 changed files with 482 additions and 79 deletions

View File

@@ -174,7 +174,7 @@ INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (k
-- v1.9: per-project defaults that new sessions inherit, plus a per-session
-- web-search override. Empty string on either prompt column means "inherit"
-- (resolved in inference.ts buildSystemPrompt). web_search_enabled is the
-- (resolved in services/system-prompt.ts buildSystemPrompt). web_search_enabled is the
-- only tri-state field: null on session = inherit from project default.
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;

View File

@@ -73,26 +73,26 @@ function makeMessage(
// ---- tests ------------------------------------------------------------------
describe('buildMessagesPayload', () => {
it('prepends a system prompt containing the project path', () => {
describe('buildMessagesPayload', async () => {
it('prepends a system prompt containing the project path', async () => {
const session = makeSession();
const project = makeProject({ path: '/tmp/my-proj' });
const result = buildMessagesPayload(session, project, []);
const result = await buildMessagesPayload(session, project, []);
expect(result).toHaveLength(1);
expect(result[0]!.role).toBe('system');
expect(result[0]!.content).toContain('/tmp/my-proj');
});
it('appends session.system_prompt to the system message when set', () => {
it('appends session.system_prompt to the system message when set', async () => {
const session = makeSession({ system_prompt: 'Be terse.' });
const project = makeProject();
const result = buildMessagesPayload(session, project, []);
const result = await buildMessagesPayload(session, project, []);
expect(result).toHaveLength(1);
expect(result[0]!.role).toBe('system');
expect(result[0]!.content).toContain('Be terse.');
});
it('returns user/assistant messages in order when no compact marker is present', () => {
it('returns user/assistant messages in order when no compact marker is present', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
@@ -101,7 +101,7 @@ describe('buildMessagesPayload', () => {
makeMessage('user', 'how are you'),
makeMessage('assistant', 'great'),
];
const result = buildMessagesPayload(session, project, history);
const result = await buildMessagesPayload(session, project, history);
// 1 system + 4 history messages
expect(result).toHaveLength(5);
expect(result[0]!.role).toBe('system');
@@ -111,7 +111,7 @@ describe('buildMessagesPayload', () => {
expect(result[4]).toMatchObject({ role: 'assistant', content: 'great' });
});
it('starts from the latest compact marker, emitting it as a system message', () => {
it('starts from the latest compact marker, emitting it as a system message', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
@@ -122,7 +122,7 @@ describe('buildMessagesPayload', () => {
makeMessage('user', 'new1'),
makeMessage('assistant', 'newreply1'),
];
const result = buildMessagesPayload(session, project, history);
const result = await buildMessagesPayload(session, project, history);
// Expect: leading base-system prompt, then the compact as system, then
// the user/assistant pair following it.
expect(result).toHaveLength(4);
@@ -135,7 +135,7 @@ describe('buildMessagesPayload', () => {
expect(result[3]).toMatchObject({ role: 'assistant', content: 'newreply1' });
});
it('uses only the most recent compact when multiple are present', () => {
it('uses only the most recent compact when multiple are present', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
@@ -146,7 +146,7 @@ describe('buildMessagesPayload', () => {
makeMessage('user', 'u3'),
makeMessage('assistant', 'final reply'),
];
const result = buildMessagesPayload(session, project, history);
const result = await buildMessagesPayload(session, project, history);
// Expect: base system + latest compact as system + the two messages
// following it. The earlier compact and pre-compact history are dropped.
expect(result).toHaveLength(4);
@@ -164,7 +164,7 @@ describe('buildMessagesPayload', () => {
expect(concatenated).not.toContain('u2');
});
it('skips streaming and cancelled assistant rows', () => {
it('skips streaming and cancelled assistant rows', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
@@ -173,14 +173,14 @@ describe('buildMessagesPayload', () => {
makeMessage('assistant', 'cancelled fragment', { status: 'cancelled' }),
makeMessage('assistant', 'final answer'),
];
const result = buildMessagesPayload(session, project, history);
const result = await buildMessagesPayload(session, project, history);
// 1 system + 1 user + 1 assistant (only the complete one)
expect(result).toHaveLength(3);
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
});
it('round-trips an assistant-with-tool_calls followed by its tool result', () => {
it('round-trips an assistant-with-tool_calls followed by its tool result', async () => {
const session = makeSession();
const project = makeProject();
const toolCall: ToolCall = {
@@ -199,7 +199,7 @@ describe('buildMessagesPayload', () => {
makeMessage('tool', '', { tool_results: toolResult }),
makeMessage('assistant', 'here it is'),
];
const result = buildMessagesPayload(session, project, history);
const result = await buildMessagesPayload(session, project, history);
// 1 system + 1 user + 1 assistant(tool_calls) + 1 tool + 1 assistant
expect(result).toHaveLength(5);
expect(result[1]).toMatchObject({ role: 'user', content: 'show me the file' });
@@ -226,7 +226,7 @@ describe('buildMessagesPayload', () => {
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
});
it('skips tool rows with no tool_results', () => {
it('skips tool rows with no tool_results', async () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
@@ -234,7 +234,7 @@ describe('buildMessagesPayload', () => {
makeMessage('tool', '', { tool_results: null }),
makeMessage('assistant', 'done'),
];
const result = buildMessagesPayload(session, project, history);
const result = await buildMessagesPayload(session, project, history);
// 1 system + 1 user + 1 assistant; the empty tool row is dropped.
expect(result).toHaveLength(3);
expect(result.find((m) => m.role === 'tool')).toBeUndefined();

View File

@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, writeFile, rm, utimes } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
loadContainerGuidance,
getContainerGuidance,
buildSystemPrompt,
_resetContainerGuidanceCacheForTests,
} from '../system-prompt.js';
import type { Agent, Project, Session } from '../../types/api.js';
// ---- fixtures ---------------------------------------------------------------
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'system-prompt-test-'));
_resetContainerGuidanceCacheForTests();
delete process.env['CONTAINER_GUIDANCE_FILE'];
});
afterEach(async () => {
delete process.env['CONTAINER_GUIDANCE_FILE'];
_resetContainerGuidanceCacheForTests();
await rm(tmpDir, { recursive: true, force: true });
});
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: 'sess',
project_id: 'proj',
name: 'test session',
model: 'test-model',
system_prompt: '',
status: 'open',
created_at: new Date(0).toISOString(),
updated_at: new Date(0).toISOString(),
agent_id: null,
web_search_enabled: null,
...overrides,
};
}
function makeProject(overrides: Partial<Project> = {}): Project {
return {
id: 'proj',
name: 'test project',
path: '/tmp/proj',
added_at: new Date(0).toISOString(),
last_session_id: null,
status: 'open',
gitea_remote: null,
default_system_prompt: '',
default_web_search_enabled: false,
...overrides,
};
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: 'agent-foo',
name: 'foo',
description: 'test agent',
system_prompt: 'Speak in haiku.',
temperature: 0.3,
tools: ['view_file'],
model: null,
source: 'global',
max_tool_calls: null,
...overrides,
};
}
// ---- tests ------------------------------------------------------------------
describe('loadContainerGuidance', () => {
it('returns file content when CONTAINER_GUIDANCE_FILE points to an existing file', async () => {
const path = join(tmpDir, 'BOOCHAT.md');
await writeFile(path, 'hello from BOOCHAT', 'utf8');
process.env['CONTAINER_GUIDANCE_FILE'] = path;
const result = await loadContainerGuidance();
expect(result).toBe('hello from BOOCHAT');
});
it('returns null when the env var points to a non-existent file', async () => {
process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'does-not-exist.md');
const result = await loadContainerGuidance();
expect(result).toBeNull();
});
it('returns null when the env var is unset and /app/BOOCHAT.md does not exist', async () => {
// env var deleted in beforeEach; /app/BOOCHAT.md doesn't exist on the
// host (the prod path only resolves inside the container).
const result = await loadContainerGuidance();
expect(result).toBeNull();
});
});
describe('getContainerGuidance (mtime-watch cache)', () => {
it('caches the content across calls when the file mtime is unchanged', async () => {
const path = join(tmpDir, 'BOOCHAT.md');
await writeFile(path, 'first content', 'utf8');
// Pin mtime to a known Date BEFORE the first call so we can restore it
// exactly after the rewrite. Capturing s.mtime then writing+restoring is
// unreliable because Date round-trips truncate sub-millisecond precision
// that the filesystem reports back via stat.mtimeMs.
const fixedTime = new Date(2020, 0, 1, 12, 0, 0);
await utimes(path, fixedTime, fixedTime);
process.env['CONTAINER_GUIDANCE_FILE'] = path;
const first = await getContainerGuidance();
expect(first).toBe('first content');
// Rewrite the file with different content, then restore mtime to the
// same fixedTime. The cache must NOT re-read because the stat is
// unchanged from its point of view.
await writeFile(path, 'NEW content the cache must NOT see', 'utf8');
await utimes(path, fixedTime, fixedTime);
const second = await getContainerGuidance();
expect(second).toBe('first content');
});
it('re-reads the file when the mtime changes', async () => {
const path = join(tmpDir, 'BOOCHAT.md');
await writeFile(path, 'first content', 'utf8');
process.env['CONTAINER_GUIDANCE_FILE'] = path;
const first = await getContainerGuidance();
expect(first).toBe('first content');
// Bump mtime explicitly so the test doesn't race the filesystem's mtime
// resolution. Future time → guaranteed different from the cached value.
await writeFile(path, 'edited content', 'utf8');
const later = new Date(Date.now() + 60_000);
await utimes(path, later, later);
const second = await getContainerGuidance();
expect(second).toBe('edited content');
});
});
describe('buildSystemPrompt', () => {
it('includes the guidance block between the base prompt and the agent overlay when guidance is non-null', async () => {
const path = join(tmpDir, 'BOOCHAT.md');
await writeFile(path, 'CONTAINER RULES GO HERE', 'utf8');
process.env['CONTAINER_GUIDANCE_FILE'] = path;
const session = makeSession();
const project = makeProject({ path: '/tmp/test-proj' });
const agent = makeAgent({ system_prompt: 'Speak in haiku.' });
const prompt = await buildSystemPrompt(project, session, agent);
const baseIdx = prompt.indexOf('/tmp/test-proj');
const guidanceIdx = prompt.indexOf('CONTAINER RULES GO HERE');
const agentIdx = prompt.indexOf('Speak in haiku.');
expect(baseIdx).toBeGreaterThanOrEqual(0);
expect(guidanceIdx).toBeGreaterThan(baseIdx);
expect(agentIdx).toBeGreaterThan(guidanceIdx);
expect(prompt).toContain('--- Container guidance ---');
expect(prompt).toContain('--- end container guidance ---');
});
it('omits the guidance block entirely (no delimiters) when guidance is null', async () => {
// Env var points to a non-existent file → getContainerGuidance returns null.
process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'never-existed.md');
const session = makeSession();
const project = makeProject({ path: '/tmp/test-proj' });
const prompt = await buildSystemPrompt(project, session, null);
expect(prompt).toContain('/tmp/test-proj');
expect(prompt).not.toContain('--- Container guidance ---');
expect(prompt).not.toContain('--- end container guidance ---');
});
});

View File

@@ -24,9 +24,10 @@ import { getAgentById } from './agents.js';
import * as compaction from './compaction.js';
import * as modelContext from './model-context.js';
import type { Broker } from './broker.js';
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
// v1.12: prompt assembly extracted to its own module. buildSystemPrompt is
// async (awaits the container-guidance loader) — buildMessagesPayload below
// is therefore async too, and its three call sites in this file await it.
import { buildSystemPrompt } from './system-prompt.js';
const DB_FLUSH_INTERVAL_MS = 500;
@@ -201,37 +202,18 @@ export interface InferenceContext {
broker: Broker;
}
// Resolution order: base prompt < agent.system_prompt < user prompt, where
// user prompt = session.system_prompt if non-empty, else project's
// default_system_prompt if non-empty, else nothing. Empty/whitespace-only
// counts as "no override" for both layers (v1.9 inherit semantics — keeps
// the column non-nullable so the existing key/value store stays put).
export function buildSystemPrompt(
project: Project,
session: Session,
agent: Agent | null
): string {
let out = BASE_SYSTEM_PROMPT(project.path);
if (agent && agent.system_prompt.trim().length > 0) {
out += '\n\n' + agent.system_prompt.trim();
}
const sessionPrompt = session.system_prompt?.trim() ?? '';
const projectPrompt = project.default_system_prompt?.trim() ?? '';
const userPrompt = sessionPrompt || projectPrompt;
if (userPrompt.length > 0) {
out += '\n\n' + userPrompt;
}
return out;
}
export function buildMessagesPayload(
// v1.12: buildSystemPrompt moved to services/system-prompt.ts. See that
// module for the resolution order doc and the container-guidance layer.
// buildMessagesPayload is async now because buildSystemPrompt awaits the
// guidance cache lookup.
export async function buildMessagesPayload(
session: Session,
project: Project,
history: Message[],
agent: Agent | null = null
): OpenAiMessage[] {
): Promise<OpenAiMessage[]> {
const out: OpenAiMessage[] = [];
const systemPrompt = buildSystemPrompt(project, session, agent);
const systemPrompt = await buildSystemPrompt(project, session, agent);
out.push({ role: 'system', content: systemPrompt });
// Find the latest compact marker — only send messages from that point onwards
@@ -1104,7 +1086,7 @@ async function runAssistantTurn(
return;
}
const messages = buildMessagesPayload(session, project, history, agent);
const messages = await buildMessagesPayload(session, project, history, agent);
// v1.11.8: resolve per-chat web-tools opt-in. Tri-state on the wire:
// - session.web_search_enabled = null → inherit project default
@@ -1172,7 +1154,7 @@ async function runCapHitSummary(
): Promise<void> {
const { sessionId, chatId, assistantMessageId, signal } = args;
const messages = buildMessagesPayload(session, project, history, agent);
const messages = await buildMessagesPayload(session, project, history, agent);
messages.push({ role: 'system', content: CAP_HIT_SUMMARY_NOTE(budget) });
const startedRow = await ctx.sql<{ started_at: string }[]>`
@@ -1433,7 +1415,7 @@ async function runDoomLoopSummary(
): Promise<void> {
const { sessionId, chatId, assistantMessageId, signal } = args;
const messages = buildMessagesPayload(session, project, history, agent);
const messages = await buildMessagesPayload(session, project, history, agent);
messages.push({ role: 'system', content: DOOM_LOOP_NOTE(loop.name) });
const startedRow = await ctx.sql<{ started_at: string }[]>`

View File

@@ -0,0 +1,83 @@
// v1.12: extracted from inference.ts to give the prompt-assembly logic its
// own home + test surface. Adds the container-guidance layer (BOOCHAT.md
// baked into the Docker image, injected between the base prompt and the
// agent block).
//
// Resolution order, last-wins on conflicts:
// base prompt
// + container guidance (this layer, NEW in v1.12)
// + agent.system_prompt (resolved from data/AGENTS.md by getAgentById)
// + session.system_prompt OR project.default_system_prompt
import { readFile, stat } from 'node:fs/promises';
import type { Agent, Project, Session } from '../types/api.js';
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
// v1.12 mtime-watch cache. Mirrors the safeStat pattern in services/agents.ts.
// On every call we stat the file; if the mtime matches the cached entry we
// return the cached content without re-reading. If the file is missing we
// cache { mtime: 0, content: null } so the not-found case still benefits
// from caching (one stat per call, no readFile attempt on a known-missing
// path). Because BOOCHAT.md is bind-mounted from the host, edits land
// immediately on the next chat turn — no container restart needed.
let cachedGuidance: { mtime: number; content: string | null } | null = null;
function resolveGuidancePath(): string {
return process.env['CONTAINER_GUIDANCE_FILE'] ?? '/app/BOOCHAT.md';
}
export async function loadContainerGuidance(): Promise<string | null> {
const path = resolveGuidancePath();
try {
return await readFile(path, 'utf8');
} catch {
return null;
}
}
export async function getContainerGuidance(): Promise<string | null> {
const path = resolveGuidancePath();
let mtimeMs: number;
try {
const s = await stat(path);
mtimeMs = s.mtimeMs;
} catch {
cachedGuidance = { mtime: 0, content: null };
return null;
}
if (cachedGuidance && cachedGuidance.mtime === mtimeMs) {
return cachedGuidance.content;
}
const content = await loadContainerGuidance();
cachedGuidance = { mtime: mtimeMs, content };
return content;
}
// Test-only: clear the cache so consecutive tests don't share state.
export function _resetContainerGuidanceCacheForTests(): void {
cachedGuidance = null;
}
export async function buildSystemPrompt(
project: Project,
session: Session,
agent: Agent | null
): Promise<string> {
let out = BASE_SYSTEM_PROMPT(project.path);
const guidance = await getContainerGuidance();
if (guidance) {
out += `\n\n--- Container guidance ---\n${guidance}\n--- end container guidance ---\n`;
}
if (agent && agent.system_prompt.trim().length > 0) {
out += '\n\n' + agent.system_prompt.trim();
}
const sessionPrompt = session.system_prompt?.trim() ?? '';
const projectPrompt = project.default_system_prompt?.trim() ?? '';
const userPrompt = sessionPrompt || projectPrompt;
if (userPrompt.length > 0) {
out += '\n\n' + userPrompt;
}
return out;
}