diff --git a/src/servers/sidecar/opencode/serve-env.test.ts b/src/servers/sidecar/opencode/serve-env.test.ts index 4354a2fc..741242e8 100644 --- a/src/servers/sidecar/opencode/serve-env.test.ts +++ b/src/servers/sidecar/opencode/serve-env.test.ts @@ -6,19 +6,25 @@ import { serveConfigContent, affordancesPath } from './serve-env'; describe('serveConfigContent', () => { test('names skills.paths and instructions exactly', () => { - const parsed = JSON.parse(serveConfigContent(['/a/skills', '/b/skills'], ['/tmp/aff.md'])); - expect(parsed).toEqual({ skills: { paths: ['/a/skills', '/b/skills'] }, instructions: ['/tmp/aff.md'] }); + const parsed = JSON.parse(serveConfigContent(['/a/skills', '/b/skills'], ['/tmp/aff.md'], 'PROMPT')); + expect(parsed.skills).toEqual({ paths: ['/a/skills', '/b/skills'] }); + expect(parsed.instructions).toEqual(['/tmp/aff.md']); + // The agent is what a session is created with; a config without it silently falls back to + // OpenCode's coding-agent framing, which is the bug this whole file exists to stop. + expect(parsed.default_agent).toBe('officer'); + expect(parsed.agent.officer.prompt).toBe('PROMPT'); + expect(parsed.agent.officer.mode).toBe('primary'); }); // An empty array is not the same as an absent key: `skills: {paths: []}` is a claim that there are none, // and merged over a config that sets some it would erase them. test('omits a key rather than sending it empty', () => { - expect(JSON.parse(serveConfigContent([], []))).toEqual({}); - expect(JSON.parse(serveConfigContent(['/a'], []))).toEqual({ skills: { paths: ['/a'] } }); + expect(JSON.parse(serveConfigContent([], [], 'P')).skills).toBeUndefined(); + expect(JSON.parse(serveConfigContent(['/a'], [], 'P')).skills).toEqual({ paths: ['/a'] }); }); test('is valid JSON for an env var — no newlines to be mangled by a shell', () => { - expect(serveConfigContent(['/a/skills'], ['/tmp/aff.md'])).not.toContain('\n'); + expect(serveConfigContent(['/a/skills'], ['/tmp/aff.md'], 'P')).not.toContain('\n'); }); test('the affordances file sits beside the serve state, not in a home', () => { diff --git a/src/servers/sidecar/opencode/serve-env.ts b/src/servers/sidecar/opencode/serve-env.ts index a9f3482d..6fc5ec98 100644 --- a/src/servers/sidecar/opencode/serve-env.ts +++ b/src/servers/sidecar/opencode/serve-env.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { getOwnerUser } from 'officerdb'; import { mintSessionCredential } from '../agent-credential'; import { affordanceBlock } from '../../affordances/load'; +import { skillsBlock } from '../../skills/load'; import { capabilityRoots } from '../../capability-sources'; /** @@ -37,6 +38,18 @@ import { capabilityRoots } from '../../capability-sources'; * Composed rather than listed file-by-file so the framing survives, and rewritten each boot so a plugin * installed since is included. * + * ── The agent, and why the default one will not do ── + * + * OpenCode's built-in `build` agent opens with "You are an AI coding agent. Help the user accomplish + * software engineering tasks." Officer's chat is not a coding session, and that framing wins over + * anything appended after it. Asked to look at a calendar, a turn on 2026-08-27 reasoned — in its own + * words — "this isn't a coding task, and I don't have access to any calendar", and never called the + * skill tool, with the calendar skill sitting in its context the whole time. The plumbing was right and + * the identity was wrong, which is the harder of the two to see. + * + * So Officer defines its own primary agent and makes it the default. `prompt` REPLACES the built-in + * system text rather than adding to it, which is the point. + * * ── Why `OPENCODE_CONFIG_CONTENT` rather than editing the owner's config ── * * It is inline JSON, it MERGES with the config files rather than replacing them (verified in the binary: @@ -53,10 +66,59 @@ export const affordancesPath = (serveCwd: string) => join(serveCwd, 'officer-aff * The config OpenCode is handed. Exported for the test: the shape is a contract with the binary, and the * cost of getting a key name wrong is silence — no error, just an agent that never sees a skill. */ -export function serveConfigContent(skillRoots: string[], instructions: string[]): string { +export const OFFICER_AGENT = 'officer'; + +/** + * What an Officer session is, in the words the model reads first. + * + * Deliberately short. The environment facts arrive as an instructions file and the capabilities arrive + * as a skills index; this only has to establish WHO IT IS, because that is the part the built-in prompt + * got wrong. The last line is not padding — it names the exact failure this replaced. + */ +export const OFFICER_AGENT_PROMPT = [ + "You are Officer, running on your owner's own server. You are talking to the owner.", + '', + 'This is not a coding session. You have a shell as the owner, the platform API, and a set of skills', + 'covering what this machine actually does: the calendar and contacts, email, the app store, deploys,', + 'the plugin system, the proxy. Software work is one of the things you are asked for, not the frame', + 'around everything else.', + '', + 'When a request matches a skill description, LOAD THE SKILL with the skill tool before answering.', + 'The index in your context names each one and when to reach for it; the body costs nothing until you', + 'open it, and answering from memory when a skill exists is how you get it wrong.', + '', + 'Never decline something because it is "not a coding task". Running this machine IS the task.', +].join('\n'); + +/** + * The agent's whole system text: who it is, then the skills index. + * + * The index is COMPILED IN rather than left to OpenCode's own `SystemPrompt.skills` block. Setting a + * custom `prompt` on an agent changed what the model received — with the built-in agent the index was + * there and the framing was wrong; with ours the framing was right and the index was gone, and the turn + * went looking for `.ics` files in the home directory instead of opening the skill that describes the + * calendar API. Both halves have to arrive at once. + * + * Ours also carries the PATH to each SKILL.md, which OpenCode's does not. That matters when the skill + * tool is not the thing that ends up reading it: a path can be opened with the file tools every harness + * already has, which is the bet `skills/load.ts` was written on. + */ +export function officerAgentPrompt(skills: string | null): string { + return skills ? `${OFFICER_AGENT_PROMPT}\n\n${skills}` : OFFICER_AGENT_PROMPT; +} + +export function serveConfigContent(skillRoots: string[], instructions: string[], agentPrompt: string): string { return JSON.stringify({ ...(skillRoots.length ? { skills: { paths: skillRoots } } : {}), ...(instructions.length ? { instructions } : {}), + agent: { + [OFFICER_AGENT]: { + description: "Officer's own assistant: this machine, not just its code.", + mode: 'primary', + prompt: agentPrompt, + }, + }, + default_agent: OFFICER_AGENT, }); } @@ -90,11 +152,14 @@ export async function buildServeEnv(serveCwd: string): Promise(config, '/api/session', { method: 'POST', - body: JSON.stringify({ location: { directory: cwd } }), + body: JSON.stringify({ agent: OFFICER_AGENT, location: { directory: cwd } }), cwd, }); if (!created?.id) throw new Error('session create returned no id');