feat: add managed system prompt optimization
Add an opt-in OpenCode plugin that replaces the built-in provider behavioral prompt with a minimal identity while preserving environment, project, MCP, skill, history, and tool context. Track the active agent per session and apply the transform only to build and plan. Keep plan/build mode reminders and permission enforcement owned by OpenCode, leave all other agents untouched, and fail safely when the expected prompt boundary is absent. Expose the feature in Behavior settings with localized guidance, explicit Save + Reload application, settings search integration, persisted boolean validation, and managed-runtime lifecycle composition that does not load the plugin while disabled or on external OpenCode servers. Document the runtime contract and cover plugin materialization, config preservation, build/plan selection, agent switching, unknown prompt formats, and settings sanitization.
This commit is contained in:
@@ -96,6 +96,7 @@ import { attachRealtimeProxy } from './lib/realtime-proxy.js';
|
||||
import { createRelayService } from './lib/relay/service.js';
|
||||
import { createRelayHostLock } from './lib/relay/host-lock.js';
|
||||
import { createAgentToolRuntime } from './lib/agent-tool/runtime.js';
|
||||
import { createSystemPromptRuntime } from './lib/system-prompt/runtime.js';
|
||||
import { createOpenChamberSessionService } from './lib/openchamber-sessions/routes.js';
|
||||
import { createScheduledTaskService } from './lib/scheduled-tasks/service.js';
|
||||
import { createOpenChamberControlService } from './lib/openchamber-control/service.js';
|
||||
@@ -269,6 +270,7 @@ const readCustomThemesFromDisk = (...args) => themeRuntime.readCustomThemesFromD
|
||||
|
||||
let notificationTemplateRuntime = null;
|
||||
let agentToolRuntime = null;
|
||||
let systemPromptRuntime = null;
|
||||
|
||||
const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args);
|
||||
const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args);
|
||||
@@ -1056,8 +1058,14 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
|
||||
getActiveSessionCount,
|
||||
getManagedOpenCodeEnv: async () => {
|
||||
const settings = await readSettingsFromDiskMigrated().catch(() => null);
|
||||
if (settings?.agentControlToolEnabled === false) return {};
|
||||
return agentToolRuntime?.prepareManagedOpenCodeEnv() || {};
|
||||
const managedEnv = settings?.agentControlToolEnabled === false
|
||||
? {}
|
||||
: await (agentToolRuntime?.prepareManagedOpenCodeEnv() || {});
|
||||
if (settings?.optimizeSystemPrompt !== true) return managedEnv;
|
||||
|
||||
const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
|
||||
const systemPromptEnv = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent);
|
||||
return { ...managedEnv, ...systemPromptEnv };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1240,6 +1248,11 @@ async function main(options = {}) {
|
||||
return typeof address === 'object' && address ? address.port : null;
|
||||
},
|
||||
});
|
||||
systemPromptRuntime = createSystemPromptRuntime({
|
||||
fsPromises,
|
||||
path,
|
||||
dataDir: OPENCHAMBER_DATA_DIR,
|
||||
});
|
||||
|
||||
// Pairing transports advertised to the create-device dialog. LAN reachability is
|
||||
// derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP;
|
||||
|
||||
@@ -29,6 +29,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup.
|
||||
- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
|
||||
- `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
|
||||
- `packages/web/server/lib/system-prompt/runtime.js`: opt-in managed OpenCode system-prompt optimizer materialization and plugin injection.
|
||||
- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
|
||||
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration.
|
||||
- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
|
||||
|
||||
@@ -494,6 +494,9 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
if (typeof candidate.agentControlToolEnabled === 'boolean') {
|
||||
result.agentControlToolEnabled = candidate.agentControlToolEnabled;
|
||||
}
|
||||
if (typeof candidate.optimizeSystemPrompt === 'boolean') {
|
||||
result.optimizeSystemPrompt = candidate.optimizeSystemPrompt;
|
||||
}
|
||||
if (typeof candidate.openCodeUpdateToastDismissedVersion === 'string') {
|
||||
const version = candidate.openCodeUpdateToastDismissedVersion.trim();
|
||||
result.openCodeUpdateToastDismissedVersion = version.slice(0, VERSION_STRING_MAX_LENGTH);
|
||||
|
||||
@@ -409,6 +409,14 @@ describe('settings helpers', () => {
|
||||
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [123, ''] } })).toEqual({});
|
||||
});
|
||||
|
||||
it('persists only boolean system prompt optimization values', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: true })).toEqual({ optimizeSystemPrompt: true });
|
||||
expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: false })).toEqual({ optimizeSystemPrompt: false });
|
||||
expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: 'true' })).toEqual({});
|
||||
});
|
||||
|
||||
it('survives a full settings.json payload containing all four previously-dropped fields (regression)', () => {
|
||||
const helpers = createTestHelpersWithRealSanitizers();
|
||||
const payload = {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Managed System Prompt Optimizer
|
||||
|
||||
## Purpose
|
||||
|
||||
This module injects an opt-in OpenCode plugin only when OpenChamber launches
|
||||
and owns the OpenCode process and `optimizeSystemPrompt` is enabled. The plugin
|
||||
replaces OpenCode's built-in behavioral/provider prompt with a short identity
|
||||
while preserving the environment, project instructions, MCP instructions,
|
||||
skills, conversation history, and separately supplied tools.
|
||||
|
||||
## Runtime flow
|
||||
|
||||
1. Settings persist `optimizeSystemPrompt` in OpenChamber's `settings.json`.
|
||||
2. The setting is applied when managed OpenCode restarts.
|
||||
3. The runtime materializes the plugin under
|
||||
`<openchamber-data-dir>/system-prompt/` and appends its `file://` URL to
|
||||
`OPENCODE_CONFIG_CONTENT` without replacing existing plugin entries.
|
||||
4. The plugin tracks the selected agent through `chat.message`. The transform
|
||||
runs only for sessions using the built-in `build` or `plan` agent.
|
||||
5. The transform locates OpenCode's environment boundary and removes only the
|
||||
preceding text. If the boundary is absent, it leaves the prompt unchanged.
|
||||
|
||||
## Limitations
|
||||
|
||||
OpenCode exposes the assembled prompt rather than structured sections. A custom
|
||||
prompt configured by overriding the `build` or `plan` agent occupies the same
|
||||
prefix as the built-in provider prompt, so the optimizer also removes that
|
||||
override. Other agents are never transformed. The setting is off by default.
|
||||
|
||||
Plan-mode restrictions and build-mode transitions are not part of the removed
|
||||
prefix. OpenCode injects those as synthetic message reminders after system
|
||||
prompt transformation and separately enforces plan restrictions through tool
|
||||
permissions.
|
||||
|
||||
The plugin is not injected for external OpenCode servers or VS Code's separate
|
||||
OpenCode lifecycle.
|
||||
@@ -0,0 +1,68 @@
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const PROVIDER_PROMPT_BOUNDARY = 'You are powered by the model named';
|
||||
const MINIMAL_IDENTITY = 'You are OpenCode, a coding agent.';
|
||||
|
||||
const createPluginSource = () => String.raw`
|
||||
const PROVIDER_PROMPT_BOUNDARY = ${JSON.stringify(PROVIDER_PROMPT_BOUNDARY)}
|
||||
const MINIMAL_IDENTITY = ${JSON.stringify(MINIMAL_IDENTITY)}
|
||||
const optimizedSessions = new Map()
|
||||
|
||||
export const OpenChamberSystemPromptPlugin = async () => ({
|
||||
"chat.message": async (input, output) => {
|
||||
if (!input.sessionID) return
|
||||
const agent = output?.message?.agent ?? input.agent
|
||||
if (agent === "build" || agent === "plan") {
|
||||
optimizedSessions.set(input.sessionID, agent)
|
||||
return
|
||||
}
|
||||
optimizedSessions.delete(input.sessionID)
|
||||
},
|
||||
event: async ({ event }) => {
|
||||
if (event?.type === "session.deleted") optimizedSessions.delete(event.properties?.info?.id)
|
||||
},
|
||||
"experimental.chat.system.transform": async (input, output) => {
|
||||
if (!input.sessionID || !optimizedSessions.has(input.sessionID)) return
|
||||
const prompt = output.system.join("\n")
|
||||
const boundary = prompt.indexOf(PROVIDER_PROMPT_BOUNDARY)
|
||||
if (boundary < 0) return
|
||||
output.system.length = 0
|
||||
output.system.push(MINIMAL_IDENTITY + "\n\n" + prompt.slice(boundary))
|
||||
},
|
||||
})
|
||||
`;
|
||||
|
||||
const mergePluginConfig = (rawConfig, pluginUrl) => {
|
||||
const errors = [];
|
||||
const parsed = typeof rawConfig === 'string' && rawConfig.trim()
|
||||
? parseJsonc(rawConfig, errors, { allowTrailingComma: true })
|
||||
: {};
|
||||
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its system prompt optimizer');
|
||||
}
|
||||
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
|
||||
throw new Error('OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its system prompt optimizer');
|
||||
}
|
||||
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
|
||||
parsed.plugin = [
|
||||
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
|
||||
pluginUrl,
|
||||
];
|
||||
return JSON.stringify(parsed);
|
||||
};
|
||||
|
||||
export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => {
|
||||
const pluginDirectory = path.join(dataDir, 'system-prompt');
|
||||
const pluginPath = path.join(pluginDirectory, 'openchamber-system-prompt-plugin.js');
|
||||
|
||||
const prepareManagedOpenCodeEnv = async (rawConfig) => {
|
||||
await fsPromises.mkdir(pluginDirectory, { recursive: true });
|
||||
await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
|
||||
return {
|
||||
OPENCODE_CONFIG_CONTENT: mergePluginConfig(rawConfig, pathToFileURL(pluginPath).href),
|
||||
};
|
||||
};
|
||||
|
||||
return { prepareManagedOpenCodeEnv };
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createSystemPromptRuntime } from './runtime.js';
|
||||
|
||||
const temporaryDirectories = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('managed system prompt runtime', () => {
|
||||
it.each(['build', 'plan'])('materializes the optimizer for the %s agent and preserves existing plugins', async (agent) => {
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-system-prompt-'));
|
||||
temporaryDirectories.push(dataDir);
|
||||
const runtime = createSystemPromptRuntime({ fsPromises: fs, path, dataDir });
|
||||
|
||||
const prepared = await runtime.prepareManagedOpenCodeEnv('{ "plugin": ["file:///existing.js"], "model": "test/model" }');
|
||||
const config = JSON.parse(prepared.OPENCODE_CONFIG_CONTENT);
|
||||
const pluginPath = path.join(dataDir, 'system-prompt', 'openchamber-system-prompt-plugin.js');
|
||||
|
||||
expect(config.model).toBe('test/model');
|
||||
expect(config.plugin).toEqual(['file:///existing.js', pathToFileURL(pluginPath).href]);
|
||||
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}`);
|
||||
const hooks = await pluginModule.OpenChamberSystemPromptPlugin();
|
||||
const output = {
|
||||
system: ['Behavioral prompt\nYou are powered by the model named GPT.\n<env>kept</env>'],
|
||||
};
|
||||
await hooks['chat.message'](
|
||||
{ sessionID: 'session-1', ...(agent === 'build' ? { agent } : {}) },
|
||||
{ message: { agent } },
|
||||
);
|
||||
await hooks['experimental.chat.system.transform']({ sessionID: 'session-1' }, output);
|
||||
expect(output.system).toEqual([
|
||||
'You are OpenCode, a coding agent.\n\nYou are powered by the model named GPT.\n<env>kept</env>',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves an unknown prompt format unchanged', async () => {
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-system-prompt-'));
|
||||
temporaryDirectories.push(dataDir);
|
||||
const runtime = createSystemPromptRuntime({ fsPromises: fs, path, dataDir });
|
||||
await runtime.prepareManagedOpenCodeEnv('{}');
|
||||
const pluginPath = path.join(dataDir, 'system-prompt', 'openchamber-system-prompt-plugin.js');
|
||||
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}`);
|
||||
const hooks = await pluginModule.OpenChamberSystemPromptPlugin();
|
||||
const output = { system: ['Unrecognized prompt'] };
|
||||
await hooks['chat.message']({ sessionID: 'session-1', agent: 'plan' });
|
||||
await hooks['experimental.chat.system.transform']({ sessionID: 'session-1' }, output);
|
||||
expect(output.system).toEqual(['Unrecognized prompt']);
|
||||
});
|
||||
|
||||
it('does not transform prompts for other agents', async () => {
|
||||
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-system-prompt-'));
|
||||
temporaryDirectories.push(dataDir);
|
||||
const runtime = createSystemPromptRuntime({ fsPromises: fs, path, dataDir });
|
||||
await runtime.prepareManagedOpenCodeEnv('{}');
|
||||
const pluginPath = path.join(dataDir, 'system-prompt', 'openchamber-system-prompt-plugin.js');
|
||||
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}`);
|
||||
const hooks = await pluginModule.OpenChamberSystemPromptPlugin();
|
||||
const output = {
|
||||
system: ['Custom agent prompt\nYou are powered by the model named GPT.\n<env>kept</env>'],
|
||||
};
|
||||
|
||||
await hooks['chat.message']({ sessionID: 'session-1', agent: 'build' });
|
||||
await hooks['chat.message']({ sessionID: 'session-1', agent: 'review' });
|
||||
await hooks['experimental.chat.system.transform']({ sessionID: 'session-1' }, output);
|
||||
|
||||
expect(output.system).toEqual([
|
||||
'Custom agent prompt\nYou are powered by the model named GPT.\n<env>kept</env>',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user