Skip to content

Latest commit

 

History

History
270 lines (212 loc) · 10.9 KB

File metadata and controls

270 lines (212 loc) · 10.9 KB

OpenCode Subagent Bridge Plugin

OpenCode plugin that routes Ouroboros MCP tool calls into native OpenCode Task panes backed by independent child sessions, giving each subagent a clean LLM context while the main session stays uncluttered.

What it does

When the main LLM invokes an Ouroboros MCP tool that emits a _subagent / _subagents envelope, the bridge dispatches the work into a child session. Tools that dispatch via the plugin when runtime_backend=opencode and opencode_mode=plugin (default):

Tool Envelope Child role
ouroboros_qa _subagent QA judge
ouroboros_lateral_think (persona=all) _subagents one child per persona
ouroboros_lateral_think (single persona) _subagent single persona child
ouroboros_interview _subagent Socratic interviewer
ouroboros_pm_interview _subagent PM interviewer
ouroboros_generate_seed _subagent seed architect
ouroboros_execute_seed _subagent executor
ouroboros_start_execute_seed _subagent executor (background job)
ouroboros_evolve_step _subagent evolution generation
ouroboros_start_evolve_step _subagent evolution (background job)
ouroboros_evaluate _subagent evaluator

For each payload the bridge fire-and-forget dispatches:

  1. Parses the envelope in the tool.execute.after hook.
  2. For each payload, AWAITS a new child session (client.session.create).
  3. AWAITS PATCH of the original tool's assistant-message part with a subtask part (state running) via direct HTTP PATCH to /session/{parent}/message/{mid}/part/{pid} so the Task pane renders inline under the tool call with a spinner.
  4. FIRES client.session.prompt(...) without awaiting — the child runs in the background.
  5. Attaches .then / .catch handlers that PATCH the widget to completed (with <task_result> output) or error on failure.
  6. Stamps the tool output with a human-readable dispatch banner + structured envelope in metadata.ouroboros_dispatch.

End result:

  • Hook returns in ~100ms — main LLM is NOT blocked on child execution.
  • Each subagent runs in its own child session — independent context, no cross-contamination.
  • Task panes appear inline with live state: running → completed/error.
  • Widget state is the source of truth for completion; OpenCode natively re-injects child output into the parent session when the widget transitions to completed.

Dispatch envelope

out.metadata.ouroboros_dispatch carries a structured record:

{
  "status": "dispatched" | "dispatch_failed" | "skipped" | "nothing",
  "mode": "plugin_subagent",
  "dispatched_at": "2026-04-17T…Z",
  "children": [{"title","childID","agent","tool","truncated"}],
  "failed":   [{"title","tool","reason?"}],
  "skipped":  [{"title","tool"}]
}

Downstream tooling can distinguish plugin-dispatched runs from subprocess runs via mode === "plugin_subagent".

When the Python handler includes contract keys in its response (e.g. job_id, session_id, status), the bridge preserves them in out.metadata.ouroboros_response_shape. This lets callers recover the original tool contract even though stamp() overwrites the text content with a human-readable banner.

Why a bridge

Ouroboros MCP tools need isolated reasoning space for operations like QA judgment, Socratic interview, multi-persona lateral thinking, and evolutionary evaluation. Directly returning the subagent's work to the main LLM pollutes its context and forces anchoring bias (especially for multi-persona fan-out where each persona must think independently). The bridge moves that work into child sessions while keeping the UI inline via subtask parts.

How it works

+--------------------+     +----------------------+     +---------------------+
| Main LLM turn      |     | Ouroboros MCP tool   |     | Bridge plugin       |
| calls ouroboros_*  | --> | emits _subagent(s)   | --> | tool.execute.after  |
+--------------------+     +----------------------+     +----------+----------+
                                                                   |
                       +-------------------------------------------+
                       |  for each payload (fire-and-forget):      |
                       v                                           |
          +-----------------------------+       +------------------+-------+
          | AWAIT session.create        |       | PATCH session/{parent}/  |
          | -> new childID              |       | message/{mid}/part/{pid} |
          | AWAIT patch: state=running  | ----> | body { type:"subtask",   |
          | FIRE session.prompt (no     |       |        sessionID:child,  |
          |   await) + .then/.catch     |       |        state:"running" } |
          +--------------+--------------+       +--------------------------+
                         |                                  ^
                         |  on child finish (bg):           |
                         +----------------------------------+
                            PATCH state=completed|error
                         |
                         v
          +-----------------------------+
          | Hook returns ~100ms         |
          | Task pane spins → completes |
          +-----------------------------+

Fan-out model (multi-persona)

ouroboros_lateral_think (and any tool emitting _subagents: [...]) spawns N independent child sessions in parallel — one per persona. Each child receives only its own prompt, so the five lateral-thinking personas produce unconflicted angles with no anchoring bias.

Aspect Behaviour
Dispatch model One child session per subagent payload
Parallelism All children spawn concurrently; patches serialized by API
Max fan-out MAX_FANOUT = 10 per tool call
Dedupe (parentSessionID, callID) identity, DEDUPE_MS window
Child context Fresh session — no inherited main-LLM context
Result surfacing Parent patched with subtask part pointing at child

Robustness

Retries and respawn

Every child dispatch wraps create → PATCH-running in a retry ladder. The session.prompt call is fire-and-forget (non-blocking); prompt failures are PATCHed to error state on the widget, not retried in-plugin:

Layer Retries Behaviour on failure
Child create Awaited; failure aborts dispatch Error surfaces as tool result
Part PATCH PATCH_RETRIES = 3 Exponential backoff (BACKOFF_MS = 100)
Part resolve RESOLVE_RETRIES = 5 Poll parent message for the tool part
Child prompt Fire-and-forget (no in-plugin retry) Widget PATCHed to error state
Child timeout CHILD_TIMEOUT_MS (default 20 min) Abort child, PATCH error state

Canonical output

Successful dispatch writes this to the parent tool output (so the main LLM can cite the child and read its final text):

task_id: {childID}

<task_result>
{last assistant text from child}
</task_result>

Errors surface in metadata.ouroboros_dispatch_errors. One failed payload does not abort the rest of a fan-out batch.

Environment knobs

Variable Default Purpose
OUROBOROS_CHILD_TIMEOUT_MS 1 200 000 Per-child overall timeout (ms)
OUROBOROS_SUB_RETRIES 2 Extra retries after first child attempt

Installation

Run ouroboros setup and select the OpenCode runtime. Install is atomic, idempotent, and content-hashed — reruns are a no-op when the plugin source is unchanged.

Platform Plugin directory
Linux ~/.config/opencode/plugins/ouroboros-bridge/ (respects $XDG_CONFIG_HOME)
macOS ~/Library/Application Support/OpenCode/plugins/ouroboros-bridge/
Windows %APPDATA%\OpenCode\plugins\ouroboros-bridge\

What setup guarantees:

  • Plugin source copied to the platform directory via os.replace (atomic).
  • opencode.json plugin array deduped — stale entries from XDG shifts, sudo migrations, or legacy paths are removed, then the canonical path is appended.
  • SHA-256 content hash compared before writing — identical content is left untouched (mtime preserved).

Restart OpenCode after setup. Verify by checking <plugin-dir>/bridge.log — you should see an INIT line. If the log is empty or OpenCode logs Plugin export is not a function, confirm the plugin source still ends with a V1 default export (export default { id, server }) — OpenCode's legacy loader scans every named export and rejects non-function values like MAX_BYTES.

Manual install (advanced)

Copy ouroboros-bridge.ts into the platform plugin directory and add its path to opencode.json:

{
  "plugin": ["/path/to/plugins/ouroboros-bridge/ouroboros-bridge.ts"]
}

Verifying the plugin

In an OpenCode session with Ouroboros MCP tools available:

> run ouroboros_qa against a sample artifact

Expected:

  1. Tool returns with task_id: ses_... <task_result>...</task_result>.
  2. A Task pane opens inline under the tool call and streams child work.
  3. bridge.log gains DISPATCH tool=ouroboros_qa child=ses_... lines.

Multi-persona:

> ouroboros_lateral_think with persona="all"

Expected: five inline Task panes, five distinct child=ses_... IDs, and five independent <task_result> blocks — no shared context.

Troubleshooting

No DISPATCH in log, no Task pane

  • Confirm the MCP tool name is prefixed ouroboros_.
  • Confirm the tool output is valid JSON with _subagent or _subagents.
  • Confirm the plugin path in opencode.json resolves to an existing file.

ERR lines in log

Common causes:

  • SDK older than v1.4.3. Run opencode upgrade.
  • Unknown agent name — bridge falls back to general automatically, but a named agent must exist in the roster.
  • Child timed out — raise OUROBOROS_CHILD_TIMEOUT_MS.

Raw JSON envelope leaks to main LLM

The plugin hook did not run. Confirm INIT line exists in bridge.log and that OpenCode was restarted after install.

Task panes not inline

If the subtask part fails to patch, output falls back to a plain dispatch note. Check ERR PATCH part=... status=... in bridge.log.

Source

Plugin source: src/ouroboros/opencode/plugin/ouroboros-bridge.ts. ouroboros setup deploys it to the platform plugin directory and keeps it in sync on every run via content-hash comparison.

See also