diff --git a/config/default.yaml b/config/default.yaml index e92b3ab..5aac13b 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -211,7 +211,7 @@ models: # opencode: # enabled: false # # path: /usr/local/bin/opencode -# # args: ["-p", "{prompt}"] +# # args: ["run", "--format", "default", "{prompt}"] # # timeout_ms: 120000 # codex: # enabled: false diff --git a/docs/runbooks/OPENCODE_CLI_SUBAGENT.md b/docs/runbooks/OPENCODE_CLI_SUBAGENT.md new file mode 100644 index 0000000..8083fce --- /dev/null +++ b/docs/runbooks/OPENCODE_CLI_SUBAGENT.md @@ -0,0 +1,137 @@ +# OpenCode CLI subagent runbook + +This runbook defines how Flynn uses the **OpenCode** CLI (`opencode`) as a *subagent*. + +Principles (same as other CLI subagents): +- Treat output as **untrusted input**: digest + sanity-check; verify with local tools/tests when possible. +- Prefer **plain text** output for easy ingestion; switch to structured output only when it provides clear benefit. +- Never claim something is true just because the model said so—verify if we can. + +--- + +## 0) Quick facts + +- Binary: `opencode` +- Default model (verified present in `opencode models`): **`opencode/glm-5-free`** +- Preferred mode: **non-interactive** (`opencode run ...`) + +--- + +## 1) Default invocation + +### 1.1 Plain text (default) +Use this for most tasks (summaries, parsing, transformation suggestions, drafting): + +```bash +opencode run -m opencode/glm-5-free --format default "${PROMPT}" +``` + +Notes: +- Keep prompts short and explicit. +- If the prompt contains quotes/newlines, wrap with a heredoc substitution: + +```bash +opencode run -m opencode/glm-5-free --format default "$(cat <<'PROMPT' +...prompt text... +PROMPT +)" +``` + +### 1.2 JSON output (selective) +Only use JSON mode when we need robust post-processing or auditing. + +```bash +opencode run -m opencode/glm-5-free --format json "${PROMPT}" +``` + +Caveat: +- `--format json` typically yields structured output that may be easier to parse, but it can be more verbose and may require filtering. + +--- + +## 2) Model selection + +Default to `opencode/glm-5-free` unless the task clearly benefits from switching. + +When to switch models: +- **Higher quality / long docs / tricky reasoning**: use a stronger model available in `opencode models` (example: `anthropic/claude-3-7-sonnet-latest`, `openai/gpt-5.3-codex`, `google/gemini-2.5-pro`). +- **Need a specific provider** (e.g. Copilot org policy): use `github-copilot/...` models. + +Rule: +- If model choice matters, be explicit in the call. + +--- + +## 3) Attach files + +OpenCode supports attaching files. Use this for document parsing tasks when the CLI supports the file type. + +```bash +opencode run -m opencode/glm-5-free --format default -f /path/to/file.pdf "Extract a table of key dates and parties. Return JSON." +``` + +Guidance: +- Prefer attaching the file over copy/pasting huge content. +- For PDFs/images, we may still prefer local tooling (Poppler, OCR) first, then provide extracted text. + +--- + +## 4) Task recipes + +### 4.1 Document search & retrieval help +Use OpenCode to: +- expand queries +- generate keywords/entities +- propose relevance criteria + +Prompt pattern: +- Provide: goal, corpus description, constraints, examples of good/bad hits. + +### 4.2 Document parsing +Use OpenCode to: +- outline structure +- extract entities / timelines +- convert semi-structured text to JSON + +Prompt pattern: +- Specify the target schema exactly. +- Ask it to quote source spans/anchors when possible. + +### 4.3 PDF manipulation (planning) +Use OpenCode to decide *what* to do; use local tools to do it. +- Example: “split pages 3–7”, “OCR then extract”, “remove password”, “linearize”, etc. + +--- + +## 5) How Flynn digests OpenCode output + +When I run OpenCode: +1. I’ll extract the actionable result (answer/plan/patch schema). +2. I’ll sanity-check for contradictions and missing constraints. +3. I’ll verify locally when possible (grep/tests/PDF tool output). +4. I’ll present you the final integrated conclusion. + +Raw OpenCode output is included only when: +- you ask for it +- it’s needed for auditing/debugging +- it contains a critical snippet (e.g., exact JSON) that must be preserved + +--- + +## 6) Troubleshooting + +- If startup fails with: + - `default agent "" is a subagent` + - then OpenCode config points `default_agent` to a non-primary agent. + - Fix by setting `default_agent` to a primary agent in `~/.config/opencode/opencode.json`, or remove that key. +- For Flynn external backend wiring, prefer: + ```bash + opencode run --format default "{prompt}" + ``` + instead of `opencode -p "{prompt}"`. +- If `opencode` behaves strangely in a repo directory, try running from a neutral cwd (e.g. `$HOME`) or ensure paths passed to `-f` are absolute. +- If a provider/model isn’t found, run: + ```bash + opencode models + ``` + and pick an available entry. diff --git a/src/backends/external.test.ts b/src/backends/external.test.ts index f46897c..a9242d5 100644 --- a/src/backends/external.test.ts +++ b/src/backends/external.test.ts @@ -59,6 +59,25 @@ describe('ExternalCliBackend', () => { ); }); + it('uses inferred run args for opencode backend', async () => { + mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + callback(null, 'ok', ''); + } + return {} as never; + }); + + const backend = new OpenCodeBackend('/usr/bin/opencode', []); + await backend.process({ prompt: 'hello', history: [] }); + + expect(mockExecFile).toHaveBeenCalledWith( + '/usr/bin/opencode', + ['run', '--format', 'default', 'USER: hello'], + expect.any(Object), + expect.any(Function), + ); + }); + it('supports {prompt} substitution in configured args', async () => { mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { if (typeof callback === 'function') { @@ -98,6 +117,19 @@ describe('ExternalCliBackend', () => { .rejects.toThrow('returned no output'); }); + it('includes stdout details when backend process fails', async () => { + mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + callback(new Error('Command failed: opencode'), 'default agent "code-planner" is a subagent', ''); + } + return {} as never; + }); + + const backend = new OpenCodeBackend('/usr/bin/opencode', []); + await expect(backend.process({ prompt: 'hello', history: [] })) + .rejects.toThrow('default agent "code-planner" is a subagent'); + }); + it('constructs default commands for opencode and gemini backends', () => { const opencode = new OpenCodeBackend(); const gemini = new GeminiBackend(); diff --git a/src/backends/external.ts b/src/backends/external.ts index 2d654e3..fdb9dc3 100644 --- a/src/backends/external.ts +++ b/src/backends/external.ts @@ -37,6 +37,9 @@ function inferArgs(name: ExternalBackendName, prompt: string): string[] { if (name === 'claude_code') { return ['--print', prompt]; } + if (name === 'opencode') { + return ['run', '--format', 'default', prompt]; + } return ['-p', prompt]; } @@ -99,7 +102,11 @@ function execFileAsync(command: string, args: string[], timeoutMs: number): Prom return new Promise((resolve, reject) => { execFile(command, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { if (error) { - reject(new Error(`${error.message}${stderr ? `\n${stderr}` : ''}`)); + const details = [stderr, stdout] + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .join('\n'); + reject(new Error(details ? `${error.message}\n${details}` : error.message)); return; } resolve(stdout || '');