Compare commits

...
Author SHA1 Message Date
Dax Raad 95ee0c82ee feat(core): add autonomous goal command 2026-08-26 16:31:40 -04:00
3 changed files with 178 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
export * as GoalPlugin from "./goal.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Session } from "@opencode-ai/schema/session"
import { Effect, Stream } from "effect"
type GoalState = {
goal: string
active: boolean
}
let workerStarted = false
export const Plugin = define({
id: "opencode.goal",
effect: Effect.fn(function* (ctx) {
const key = (sessionID: Session.ID) => `session/${sessionID}/goal`
const read = Effect.fn(function* (sessionID: Session.ID) {
return (yield* ctx.storage.get(key(sessionID))) as GoalState | undefined
})
const evaluate = Effect.fn(function* (sessionID: Session.ID) {
const state = yield* read(sessionID)
if (!state?.active) return
const result = yield* ctx.session.generate({
sessionID,
prompt: [
"Evaluate progress toward the goal below using the current session context.",
"Reply with exactly COMPLETE if it is fully complete.",
"Otherwise reply with CONTINUE followed by one concise instruction for the next step.",
`Goal: ${state.goal}`,
].join("\n\n"),
})
const current = yield* read(sessionID)
if (!current?.active || current.goal !== state.goal) return
const evaluation = result.text.trim()
if (/^COMPLETE\b/i.test(evaluation)) {
yield* ctx.session.synthetic({
sessionID,
text: `Goal: ${state.goal}\n\nThe goal has been completed.`,
description: "Goal completed",
delivery: "steer",
resume: false,
})
yield* ctx.storage.set(key(sessionID), { goal: state.goal, active: false })
return
}
yield* ctx.session.synthetic({
sessionID,
text: [
`Goal: ${state.goal}`,
`Next step: ${evaluation.replace(/^CONTINUE\s*/i, "")}`,
"Continue working autonomously until the goal is complete.",
].join("\n\n"),
description: "Goal continuing",
delivery: "steer",
resume: true,
})
})
if (!workerStarted) {
workerStarted = true
yield* ctx.event
.subscribe()
.pipe(
Stream.mapEffect((event) => {
if (event.type !== "session.execution.succeeded") return Effect.void
return evaluate(event.data.sessionID).pipe(
Effect.catch((error) => Effect.logError("goal evaluation failed", { sessionID: event.data.sessionID, error })),
)
}),
Stream.runDrain,
Effect.forkDetach,
)
}
yield* ctx.command.transform((draft) => {
draft.add({
name: "goal",
description: "Work autonomously toward a goal",
execute: Effect.fn(function* ({ sessionID, prompt, delivery }) {
const goal = prompt.text.trim()
if (!goal) return yield* Effect.fail(new Error("Usage: /goal <goal>"))
yield* ctx.storage.set(key(sessionID), { goal, active: true })
yield* ctx.session.synthetic({
sessionID,
text: `Goal: ${goal}\n\nContinue until the goal is fully complete. Use tools and make changes as needed.`,
description: `Goal started: ${goal}`,
delivery,
resume: true,
})
}),
})
})
}),
})
+2
View File
@@ -76,6 +76,7 @@ import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import { CommandPlugin } from "./command.js"
import { GoalPlugin } from "./goal.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { MCPCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
@@ -241,6 +242,7 @@ const pre = [
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
GoalPlugin.Plugin,
SkillPlugin.Plugin,
VcsHgPlugin.Plugin,
...SystemPromptPlugin.Plugins,
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect } from "bun:test"
import type { CommandDefinition } from "@opencode-ai/plugin/effect/command"
import { Event } from "@opencode-ai/schema/event"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { DateTime, Deferred, Effect, PubSub, Stream } from "effect"
import { GoalPlugin } from "@opencode-ai/core/plugin/goal"
import { it } from "../lib/effect"
import { host } from "./host"
const sessionID = Session.ID.make("ses_goal_test")
describe("GoalPlugin.Plugin", () => {
it.effect("continues a goal until evaluation reports completion", () =>
Effect.gen(function* () {
const event: SessionEvent.Execution.Succeeded = {
id: Event.ID.create(),
created: 0,
durable: { aggregateID: sessionID, seq: Event.Seq.make(0), version: Event.Version.make(1) },
type: "session.execution.succeeded",
data: { sessionID },
}
const events = yield* PubSub.unbounded<typeof event>()
const completed = yield* Deferred.make<void>()
const storage = new Map<string, unknown>()
const descriptions = new Array<string>()
let command: CommandDefinition | undefined
yield* GoalPlugin.Plugin.effect(
host({
command: {
list: () => Effect.die("unused command.list"),
reload: () => Effect.die("unused command.reload"),
transform: (callback) => {
callback({ add: (definition) => (command = definition) })
return Effect.succeed({ dispose: Effect.void })
},
},
event: { subscribe: () => Stream.fromPubSub(events) },
storage: {
get: (key) => Effect.succeed(storage.get(key) as never),
set: (key, value) => Effect.sync(() => storage.set(key, value)),
remove: (key) => Effect.sync(() => storage.delete(key)),
scan: () => Effect.die("unused storage.scan"),
},
session: {
generate: () => Effect.succeed({ text: "COMPLETE" }),
synthetic: (input) =>
Effect.gen(function* () {
descriptions.push(input.description ?? "")
if (input.description === "Goal completed") yield* Deferred.succeed(completed, undefined)
return SessionInbox.Synthetic.make({
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
timeCreated: DateTime.makeUnsafe(0),
type: "synthetic",
payload: { text: input.text, description: input.description },
delivery: input.delivery ?? "steer",
})
}),
},
}),
)
yield* Effect.yieldNow
if (!command) return yield* Effect.die("Goal command was not registered")
yield* command.execute({ sessionID, prompt: { text: "Finish the task" }, delivery: "steer" })
yield* PubSub.publish(events, event)
yield* Deferred.await(completed)
expect(descriptions).toEqual(["Goal started: Finish the task", "Goal completed"])
expect(storage.get(`session/${sessionID}/goal`)).toEqual({ goal: "Finish the task", active: false })
}),
)
})