Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 5f945b57a5 feat(core): add GPT delegation plugin with Ultra mode 2026-09-10 20:36:30 -05:00
7 changed files with 347 additions and 18 deletions
+121
View File
@@ -0,0 +1,121 @@
export * as GptDelegationPlugin from "./gpt-delegation.js"
import { Message } from "@opencode/ai"
import { define } from "@opencode/plugin/effect/plugin"
import { Model } from "@opencode/schema/model"
import type { SessionEvent } from "@opencode/schema/session-event"
import { Effect, Stream } from "effect"
const proactive = `<system-reminder>
Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning subagents no longer applies. This mode remains active until a later reminder changes it. User requests override this hint.
If at any point you can parallelize work by delegating tasks to a subagent, you should do so if it could save time or improve quality.
</system-reminder>`
const explicit = `<system-reminder>
Any earlier instruction enabling proactive multi-agent delegation no longer applies. Do not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.
</system-reminder>`
const removed = `<system-reminder>
The previous delegation-mode instructions no longer apply.
</system-reminder>`
export const Plugin = define({
id: "opencode.gpt-delegation",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((catalog) => {
for (const provider of catalog.provider.list()) {
for (const model of provider.models.values()) {
const selected = effort(model)
if (!selected) continue
const variant = model.variants.find((item) => item.id === selected)
if (!variant) continue
catalog.model.update(model.providerID, model.id, (draft) => {
draft.variants = [
...draft.variants.filter((item) => item.id !== "ultra"),
{ ...variant, id: Model.VariantID.make("ultra") },
]
})
}
}
})
// Synthetic reminders are durable. Render them in place as chronological system
// messages, and restore the selected policy if compaction or revert removed it.
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const catalog = (yield* ctx.catalog.model.list()).data
const last = event.messages.reduce<string | undefined>((last, message, index) => {
const text = reminder(message)
if (!text) return last
event.messages[index] = Message.make({ id: message.id, role: "system", content: text })
return text
}, undefined)
const text = policy(event.model, catalog) ?? (last ? removed : undefined)
if (!text || text === last) return
const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length
event.messages.splice(at, 0, Message.system(text))
yield* ctx.session
.synthetic({ sessionID: event.sessionID, text, resume: false })
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to persist GPT delegation reminder", { sessionID: event.sessionID, cause }),
),
)
}).pipe(
Effect.catch((error) =>
Effect.logWarning("failed to reconcile GPT delegation policy", { sessionID: event.sessionID, error }),
),
),
)
yield* ctx.event.subscribe().pipe(
Stream.filter(
(event): event is SessionEvent.Created | SessionEvent.ModelSelected =>
event.type === "session.created" || event.type === "session.model.selected",
),
Stream.runForEach((event) =>
Effect.gen(function* () {
const catalog = (yield* ctx.catalog.model.list()).data
const current = policy(event.data.model, catalog)
const previous = event.type === "session.model.selected" ? policy(event.data.previous, catalog) : undefined
if (current === previous) return
yield* ctx.session
.synthetic({ sessionID: event.data.sessionID, text: current ?? removed, resume: false })
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to inject GPT delegation reminder", {
sessionID: event.data.sessionID,
cause,
}),
),
)
}),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function effort(model: Model.Info) {
const family = model.family?.toLowerCase()
const id = model.modelID.toLowerCase()
if (family === "gpt-astra" || id.includes("gpt-6-astra")) return "xhigh"
if (family === "gpt-sol" || family === "gpt-terra" || id.includes("gpt-5.6-sol") || id.includes("gpt-5.6-terra"))
return "max"
}
function policy(ref: Model.Ref | undefined, catalog: ReadonlyArray<Model.Info>) {
if (!ref) return
const model = catalog.find((model) => model.providerID === ref.providerID && model.id === ref.id)
if (!model || ![model.modelID, model.family].some((id) => id?.toLowerCase().includes("gpt"))) return
return ref.variant === "ultra" && effort(model) && model.variants.some((item) => item.id === "ultra")
? proactive
: explicit
}
function reminder(message: Message) {
if (message.role !== "user" && message.role !== "system") return
if (message.content.length !== 1) return
const part = message.content[0]
if (part?.type !== "text") return
return part.text === proactive || part.text === explicit || part.text === removed ? part.text : undefined
}
+2
View File
@@ -83,6 +83,7 @@ import BrowserPlugin from "@opencode/plugin-browser"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { GptDelegationPlugin } from "./gpt-delegation.js"
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
@@ -241,6 +242,7 @@ const post = [
ConfigWebSearchPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
VariantPlugin.Plugin,
GptDelegationPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
@@ -38,8 +38,3 @@ In your final answer back to the user, focus on the most important information.
- Do not introduce unsolicited warnings, disclaimers, approval flows, or safety/compliance checklists due to hypothetical risk.
- Do not write tests for reversible, low-impact changes or that mirror the implementation. If you do choose to verify your work with tests, make sure that the tests are meaningful and necessary to verify implementation.
- Run tests appropriate to the change and complete required checks. Once those pass, broaden or repeat testing only when new changes, failures, or unresolved concerns justify it; otherwise, continue toward completing the task.
# Delegation
Do not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.
@@ -26,10 +26,6 @@ In the final answer, lead with the outcome, not the steps you took to reach it.
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
# Delegation
Do not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.
# Destructive actions
Do not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.
@@ -13,7 +13,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
},
"response": {
"status": 200,
@@ -0,0 +1,220 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode/ai"
import { Catalog } from "@opencode/core/catalog"
import { Config } from "@opencode/core/config"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Location } from "@opencode/core/location"
import { GptDelegationPlugin } from "@opencode/core/plugin/gpt-delegation"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { Agent } from "@opencode/schema/agent"
import { Event } from "@opencode/schema/event"
import { Model } from "@opencode/schema/model"
import { Provider } from "@opencode/schema/provider"
import { Session } from "@opencode/schema/session"
import type { SessionEvent } from "@opencode/schema/session-event"
import { SessionInbox } from "@opencode/schema/session-inbox"
import { SessionMessage } from "@opencode/schema/session-message"
import type { SessionContext } from "@opencode/plugin/effect/session"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { DateTime, Deferred, Effect, Stream } from "effect"
import { tempLocationLayer } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { catalogHost, host } from "./host"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Catalog.node, PluginHooks.node]), [
Config.node.replace(Config.testLayer()),
Location.node.replace(tempLocationLayer),
]),
)
const provider = Provider.ID.make("test")
const sessionID = Session.ID.make("ses_gpt_delegation")
const max = { id: Model.VariantID.make("max"), settings: { reasoningEffort: "max" } }
const xhigh = { id: Model.VariantID.make("xhigh"), settings: { reasoningEffort: "xhigh" } }
const model = (id: string, variant: string | undefined) =>
Model.Ref.make({
providerID: provider,
id: Model.ID.make(id),
...(variant ? { variant: Model.VariantID.make(variant) } : {}),
})
const selected = (current: Model.Ref, previous: Model.Ref): SessionEvent.ModelSelected => ({
id: Event.ID.create(),
created: 0,
durable: { aggregateID: sessionID, seq: Event.Seq.make(0), version: Event.Version.make(1) },
type: "session.model.selected",
data: { sessionID, model: current, previous },
})
const setup = Effect.gen(function* () {
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
const persisted: SessionInbox.Synthetic[] = []
const completed = yield* Deferred.make<void>()
yield* catalog.transform((editor) => {
editor.model.update(provider, Model.ID.make("sol"), (item) => {
item.modelID = Model.ID.make("gpt-5.6-sol")
item.family = Model.Family.make("custom-family")
item.variants = [
{ ...max, headers: { "x-reasoning": "max" }, body: { include: ["reasoning.encrypted_content"] } },
{ id: Model.VariantID.make("ultra"), settings: { reasoningEffort: "stale" } },
]
})
editor.model.update(provider, Model.ID.make("terra"), (item) => {
item.family = Model.Family.make("gpt-terra")
item.variants = [max]
})
editor.model.update(provider, Model.ID.make("astra"), (item) => {
item.modelID = Model.ID.make("gpt-6-astra")
item.variants = [max, { ...xhigh, headers: { "x-reasoning": "xhigh" } }]
})
editor.model.update(provider, Model.ID.make("no-xhigh"), (item) => {
item.family = Model.Family.make("gpt-astra")
item.variants = [max]
})
editor.model.update(provider, Model.ID.make("claude"), (item) => {
item.variants = [{ id: Model.VariantID.make("ultra") }]
})
})
return {
catalog,
persisted,
completed: Deferred.await(completed),
activate: (events: ReadonlyArray<SessionEvent.Created | SessionEvent.ModelSelected>) =>
GptDelegationPlugin.Plugin.effect(
host({
catalog: catalogHost(catalog),
event: {
subscribe: () => Stream.fromIterable(events).pipe(Stream.ensuring(Deferred.succeed(completed, undefined))),
},
session: {
hook: (name, callback, options) => hooks.register("session", name, callback, options),
synthetic: (input) =>
Effect.sync(() => {
expect(input.resume).toBe(false)
const item = SessionInbox.Synthetic.make({
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
timeCreated: DateTime.makeUnsafe(0),
type: "synthetic",
payload: { text: input.text },
delivery: "steer",
})
persisted.push(item)
return item
}),
},
}),
),
prepare: (ref: Model.Ref, messages: Message[]) => {
const event: SessionContext = {
sessionID,
agent: Agent.ID.make("build"),
model: ref,
system: [SystemPart.make("baseline")],
messages,
tools: {},
options: {},
}
return hooks.trigger("session", "context", event)
},
}
})
describe("GptDelegationPlugin", () => {
it.effect("copies complete max or xhigh variants using either model identity signal", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.activate([])
const sol = yield* fixture.catalog.model.get(provider, Model.ID.make("sol"))
const terra = yield* fixture.catalog.model.get(provider, Model.ID.make("terra"))
const astra = yield* fixture.catalog.model.get(provider, Model.ID.make("astra"))
const noXhigh = yield* fixture.catalog.model.get(provider, Model.ID.make("no-xhigh"))
expect(sol?.variants.filter((item) => item.id === "ultra")).toEqual([
{
...max,
headers: { "x-reasoning": "max" },
body: { include: ["reasoning.encrypted_content"] },
id: Model.VariantID.make("ultra"),
},
])
expect(terra?.variants.find((item) => item.id === "ultra")).toEqual({ ...max, id: Model.VariantID.make("ultra") })
expect(astra?.variants.find((item) => item.id === "ultra")).toEqual({
...xhigh,
headers: { "x-reasoning": "xhigh" },
id: Model.VariantID.make("ultra"),
})
expect(noXhigh?.variants.some((item) => item.id === "ultra")).toBe(false)
}),
)
it.effect("reconciles missing and stale policies as system messages without changing the baseline", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.activate([])
expect((yield* fixture.prepare(model("claude", "ultra"), [Message.user("hello")])).messages).toEqual([
Message.user("hello"),
])
const normal = yield* fixture.prepare(model("sol", undefined), [Message.user("hello")])
const explicit = fixture.persisted[0]
if (!explicit) throw new Error("Missing explicit-only reminder")
expect(normal.messages.map((message) => message.role)).toEqual(["system", "user"])
expect(explicit.payload.text).toContain("Do not spawn subagents unless")
const ultra = yield* fixture.prepare(model("sol", "ultra"), [
Message.user(explicit.payload.text),
Message.user("continue"),
])
const proactive = fixture.persisted[1]
if (!proactive) throw new Error("Missing proactive reminder")
expect(ultra.system).toEqual(normal.system)
expect(ultra.messages.map((message) => message.role)).toEqual(["system", "system", "user"])
expect(proactive.payload.text).toContain("Proactive multi-agent delegation is active")
expect((yield* fixture.prepare(model("astra", "ultra"), [...ultra.messages])).messages).toEqual(ultra.messages)
expect(fixture.persisted).toHaveLength(2)
const other = yield* fixture.prepare(model("claude", undefined), [...ultra.messages])
expect(fixture.persisted[2]?.payload.text).toContain("previous delegation-mode instructions no longer apply")
expect((yield* fixture.prepare(model("claude", undefined), [...other.messages])).messages).toEqual(other.messages)
expect(fixture.persisted).toHaveLength(3)
yield* fixture.prepare(model("sol", undefined), [Message.user(proactive.payload.text), Message.user("continue")])
expect(fixture.persisted.at(-1)?.payload.text).toBe(explicit.payload.text)
// A checkpoint quote is history, not a live mode reminder.
const compacted = yield* fixture.prepare(model("sol", "ultra"), [
Message.user(`<conversation-checkpoint>\n${proactive.payload.text}\n</conversation-checkpoint>`),
])
expect(compacted.messages[0]).toEqual(Message.system(proactive.payload.text))
expect(fixture.persisted).toHaveLength(5)
yield* fixture.prepare(model("sol", undefined), [Message.user("reverted history")])
expect(fixture.persisted.at(-1)?.payload.text).toBe(explicit.payload.text)
}),
)
it.effect("persists model-switch reminders and renders them in place on later requests", () =>
Effect.gen(function* () {
const fixture = yield* setup
yield* fixture.activate([
selected(model("sol", undefined), model("claude", undefined)),
selected(model("sol", "ultra"), model("sol", undefined)),
selected(model("astra", "ultra"), model("sol", "ultra")),
selected(model("claude", undefined), model("astra", "ultra")),
])
yield* fixture.completed
expect(fixture.persisted.map((item) => item.payload.text)).toEqual([
expect.stringContaining("Do not spawn subagents unless"),
expect.stringContaining("Proactive multi-agent delegation is active"),
expect.stringContaining("previous delegation-mode instructions no longer apply"),
])
const request = yield* fixture.prepare(model("claude", undefined), [
...fixture.persisted.map((item) => Message.make({ id: item.id, role: "user", content: item.payload.text })),
Message.user("continue"),
])
expect(request.messages).toEqual([
...fixture.persisted.map((item) => Message.make({ id: item.id, role: "system", content: item.payload.text })),
Message.user("continue"),
])
expect(fixture.persisted).toHaveLength(3)
}),
)
})
+3 -8
View File
@@ -63,12 +63,7 @@ import { Document, Info } from "@opencode/schema/config"
import { ConfigCompaction } from "@opencode/schema/config/compaction"
import { Tool } from "@opencode/core/tool"
import type { Info as ToolInfo } from "@opencode/schema/tool"
import {
InstructionStateTable,
SessionInboxTable,
SessionMessageTable,
SessionTable,
} from "@opencode/core/session/sql"
import { InstructionStateTable, SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode/core/session/sql"
import { InstructionEntry } from "@opencode/core/session/instruction-entry"
import { SessionStore } from "@opencode/core/session/store"
import { Instructions } from "@opencode/core/instructions/index"
@@ -1700,7 +1695,7 @@ describe("SessionRunnerLLM", () => {
yield* s.resume
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
expect.stringContaining("# Delegation"),
expect.stringContaining("## Final Answer"),
"Initial context",
])
})
@@ -1720,7 +1715,7 @@ describe("SessionRunnerLLM", () => {
yield* s.resume
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
expect.stringContaining("# Delegation"),
expect.stringContaining("## Final Answer"),
"Initial context",
])
})