opencode gets an identity, not just a coding agent's
three turns in a row could not look at a calendar while holding the calendar skill, and each failure had a different cause. FIRST: opencode's built-in `build` agent opens with "You are an AI coding agent. Help the user accomplish software engineering tasks." asked about a calendar it 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. the index was 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 — mode primary, `default_agent`, and named explicitly on session create because a default is a preference and this is a requirement. only NEW sessions: a resumed one keeps the agent it was made with. SECOND: setting a custom `prompt` traded one problem for the other. 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, so the turn went looking for .ics files in the home directory. `prompt` replaces the assembled system text rather than adding to it. the index is now compiled INTO the prompt by `skillsBlock()` — the same function a claude session uses — so both halves arrive together. ours also carries the PATH to each SKILL.md, which opencode's own block does not: a path can be opened with the file tools every harness already has. WHAT IS NOT FIXED, AND IS NOT THIS FILE'S FAULT. a third attempt still failed, and a diagnostic turn reported system instructions beginning "Tool infrastructure You can call" — not ours — and named a skill (`frontend-design`) that exists nowhere in this install. the only provider this machine can reach is opencode's own hosted gateway on free models; there is no auth.json here. so either the gateway supplies its own system prompt, or a free model is confabulating about its instructions. those are different problems and neither can be told apart without a model whose credential we own. verified: the agent is registered and its prompt is 4,717 characters containing "Calendar and contacts" and the SKILL.md paths; the serve's credential works (a tool process fetched the owner's collections); 10 skills discovered. NOT verified: that any of it reaches the model on this box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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<Record<string, st
|
||||
|
||||
const skillRoots: string[] = [];
|
||||
const instructions: string[] = [];
|
||||
let skills: string | null = null;
|
||||
|
||||
try {
|
||||
// Only roots that exist: OpenCode is handed these verbatim and a missing directory is noise in its
|
||||
// log for something the platform already knows is not there.
|
||||
for (const root of await capabilityRoots('skills')) if (existsSync(root)) skillRoots.push(root);
|
||||
// The same index a Claude session is given, compiled by the same function.
|
||||
skills = await skillsBlock();
|
||||
} catch (err) {
|
||||
console.error('[opencode] could not resolve the skill roots', err);
|
||||
}
|
||||
@@ -110,7 +175,10 @@ export async function buildServeEnv(serveCwd: string): Promise<Record<string, st
|
||||
console.error('[opencode] could not write the affordances file', err);
|
||||
}
|
||||
|
||||
env.OPENCODE_CONFIG_CONTENT = serveConfigContent(skillRoots, instructions);
|
||||
console.log(`[opencode] offering ${skillRoots.length} skill root(s), ${instructions.length} instruction file(s)`);
|
||||
env.OPENCODE_CONFIG_CONTENT = serveConfigContent(skillRoots, instructions, officerAgentPrompt(skills));
|
||||
console.log(
|
||||
`[opencode] offering ${skillRoots.length} skill root(s), ${instructions.length} instruction file(s), ` +
|
||||
`${skills ? 'index compiled into the agent prompt' : 'NO skills index'}`,
|
||||
);
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChatEvent, MessageCost } from '../../api/chat/types';
|
||||
import type { OpenCodeRunParams } from '../protocol';
|
||||
import { mapServeEvent } from './serve-events';
|
||||
import { OFFICER_AGENT } from './serve-env';
|
||||
|
||||
// How an OpenCode turn runs. The only way, since 2026-08-10.
|
||||
//
|
||||
@@ -219,9 +220,20 @@ export async function runOpenCodeTurnOnServe(
|
||||
|
||||
try {
|
||||
if (!openCodeSessionId) {
|
||||
/*
|
||||
The agent is named, not left to `default_agent`.
|
||||
|
||||
The config sets that too, but a default is a preference and this is a requirement: a session
|
||||
that falls back to OpenCode's built-in `build` agent is told it is a coding agent, and then
|
||||
declines anything that is not code — verified on 2026-08-27, where a turn refused to look at a
|
||||
calendar while holding the calendar skill. See serve-env.ts.
|
||||
|
||||
Only NEW sessions. A resumed one keeps whatever agent it was created with, so a conversation
|
||||
started before this stays as it was.
|
||||
*/
|
||||
const created = await serveJson<{ id: string }>(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');
|
||||
|
||||
Reference in New Issue
Block a user