Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/plugin/skill/customize-opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ Hook surface (mutate `output` in place; return `void`):
- `permission.ask`
- `experimental.chat.messages.transform`, `experimental.chat.system.transform`,
`experimental.session.compacting`, `experimental.compaction.autocontinue`,
`experimental.text.complete`
`experimental.text.complete`, `experimental.session.stopping`

Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
`auth: { ... }`, `provider: { ... }`.
Expand Down
33 changes: 33 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,39 @@ const layer = Layer.effect(
callID: orphan.callID,
})
}
// Give plugins a chance to keep the turn alive (for example to run a
// check and hand its findings back to the model). Errored turns and
// runs that reached the agent's step limit always stop, so a plugin
// cannot extend a run past that limit.
const agent = yield* agents.get(lastUser.agent)
const stopping: { continue: boolean; message?: string } = { continue: false }
if (!lastAssistant.error && step < (agent?.steps ?? Infinity))
yield* plugin.trigger(
"experimental.session.stopping",
{ sessionID, messageID: lastAssistant.id },
stopping,
)
if (stopping.continue && stopping.message) {
const continueMsg = yield* sessions.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
time: { created: Date.now() },
agent: lastUser.agent,
model: lastUser.model,
})
yield* sessions.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID,
type: "text",
synthetic: true,
text: stopping.message,
time: { start: Date.now(), end: Date.now() },
})
yield* Effect.logInfo("loop continued by plugin", { "session.id": sessionID, step })
continue
}
yield* Effect.logInfo("exiting loop", { "session.id": sessionID })
break
}
Expand Down
112 changes: 111 additions & 1 deletion packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { expect } from "bun:test"
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { fileURLToPath, pathToFileURL } from "url"
import { NamedError } from "@opencode-ai/core/util/error"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
Expand Down Expand Up @@ -2468,3 +2468,113 @@ noLLMServer.instance(
}),
30_000,
)

// Plugin that keeps the loop alive once through experimental.session.stopping.
// `withMessage: false` proves that `continue` alone is not enough to re-enter.
const stoppingPlugin = Effect.fn("test.stoppingPlugin")(function* (input: { withMessage: boolean }) {
const { directory: dir } = yield* TestInstance
const file = path.join(dir, "stopping.ts")
yield* writeText(
file,
[
"let fired = 0",
"export default async () => ({",
' "experimental.session.stopping": async (_input, output) => {',
" fired += 1",
" if (fired > 1) return",
" output.continue = true",
input.withMessage ? ' output.message = "check failed, keep going"' : "",
" },",
"})",
"",
].join("\n"),
)
return pathToFileURL(file).href
})

it.instance(
"loop runs another step when a plugin continues from session.stopping",
() =>
Effect.gen(function* () {
const plugin = yield* stoppingPlugin({ withMessage: true })
const { llm } = yield* useServerConfig((url) => ({ ...providerCfg(url), plugin: [plugin] }))
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })

yield* llm.push(reply().text("first").stop(), reply().text("second").stop())
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
const result = yield* prompt.loop({ sessionID: chat.id })
const messages = yield* sessions.messages({ sessionID: chat.id })
const users = messages.filter((m) => m.info.role === "user")
const assistants = messages.filter((m) => m.info.role === "assistant")
const hits = yield* llm.hits

expect(hits).toHaveLength(2)
expect(JSON.stringify(hits[1].body)).toContain("check failed, keep going")
expect(users).toHaveLength(2)
expect(users[1].parts).toEqual([
expect.objectContaining({ type: "text", text: "check failed, keep going", synthetic: true }),
])
expect(assistants).toHaveLength(2)
expect(assistants[1].info.role === "assistant" && assistants[1].info.parentID).toBe(users[1].info.id)
expect(result.parts).toEqual(expect.arrayContaining([expect.objectContaining({ type: "text", text: "second" })]))
}),
30_000,
)

it.instance(
"loop stops when session.stopping continues without a message",
() =>
Effect.gen(function* () {
const plugin = yield* stoppingPlugin({ withMessage: false })
const { llm } = yield* useServerConfig((url) => ({ ...providerCfg(url), plugin: [plugin] }))
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })

yield* llm.push(reply().text("first").stop())
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* prompt.loop({ sessionID: chat.id })
const messages = yield* sessions.messages({ sessionID: chat.id })

expect(yield* llm.hits).toHaveLength(1)
expect(messages.filter((m) => m.info.role === "user")).toHaveLength(1)
expect(messages.filter((m) => m.info.role === "assistant")).toHaveLength(1)
}),
30_000,
)

it.instance("loop stops after one step without a session.stopping plugin", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })

yield* llm.push(reply().text("first").stop())
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
const result = yield* prompt.loop({ sessionID: chat.id })
const messages = yield* sessions.messages({ sessionID: chat.id })

expect(yield* llm.hits).toHaveLength(1)
expect(messages.filter((m) => m.info.role === "user")).toHaveLength(1)
expect(messages.filter((m) => m.info.role === "assistant")).toHaveLength(1)
expect(result.parts).toEqual(expect.arrayContaining([expect.objectContaining({ type: "text", text: "first" })]))
}),
)
14 changes: 14 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,20 @@ export interface Hooks {
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => Promise<void>
/**
* Called when the agent loop is about to end because the assistant's last
* message finished without tool calls. Set `continue` to `true` and provide
* a `message` to append it as a synthetic user message and run another
* step instead of ending the turn.
*
* Not called for turns that ended with an error or once the agent's `steps`
* limit is reached. A plugin that always continues keeps the loop running
* until that limit, so plugins must keep their own stopping condition.
*/
"experimental.session.stopping"?: (
input: { sessionID: string; messageID: string },
output: { continue: boolean; message?: string },
) => Promise<void>
/**
* Modify tool definitions (description and parameters) sent to LLM
*/
Expand Down
27 changes: 27 additions & 0 deletions packages/web/src/content/docs/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -387,3 +387,30 @@ Format as a structured prompt that a new agent can use to resume work.
```

When `output.prompt` is set, it completely replaces the default compaction prompt. The `output.context` array is ignored in this case.

---

### Keep the agent working

Run a check when the agent is about to end its turn and hand the result back to it:

```ts title=".opencode/plugins/stopping.ts"
import type { Plugin } from "@opencode-ai/plugin"

export const StoppingPlugin: Plugin = async (ctx) => {
const checked = new Set<string>()
return {
"experimental.session.stopping": async (input, output) => {
// Only run the check once per session so the loop cannot run forever
if (checked.has(input.sessionID)) return
checked.add(input.sessionID)
const result = await ctx.$`npm run typecheck`.nothrow().quiet()
if (result.exitCode === 0) return
output.continue = true
output.message = `Type check failed, fix these errors:\n${result.stderr.toString()}`
},
}
}
```

The `experimental.session.stopping` hook fires when the assistant's last message finished without tool calls and the loop is about to end. Setting `output.continue` to `true` together with `output.message` appends the message as a synthetic user message and runs another step. The hook is not called for turns that ended with an error or once the agent's `steps` limit is reached. A plugin that always continues keeps the loop running until that limit, so keep your own stopping condition.
Loading