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
24 changes: 19 additions & 5 deletions packages/core/src/public/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * as OpenCode from "./opencode"

import { Context, Effect, Layer } from "effect"
import { Catalog } from "../catalog"
import { AgentV2 } from "../agent"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { LocationServiceMap } from "../location-layer"
Expand All @@ -12,12 +13,13 @@ import * as SessionExecutionLocal from "../session/execution/local"
import { SessionProjector } from "../session/projector"
import { SessionStore } from "../session/store"
import { ApplicationTools } from "../tool/application-tools"
import { TaskTool } from "../tool/task"
import { Session } from "./session"
import { Tool } from "./tool"

export interface Interface {
readonly sessions: Session.Interface
readonly tools: Tool.Interface
readonly session: Session.Interface
readonly tool: Tool.Interface
}

/** Intentional public native API for Effect applications embedding OpenCode. */
Expand Down Expand Up @@ -77,22 +79,32 @@ const SessionsLayer = Layer.merge(
Layer.orDie,
),
SessionModelValidationLayer,
).pipe(Layer.provide(LocationServicesLayer))
).pipe(Layer.provideMerge(LocationServicesLayer))
const ApplicationToolsLayer = ApplicationTools.layer

// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const locations = yield* LocationServiceMap
const tools = yield* ApplicationTools.Service
const validation = yield* SessionModelValidation
yield* tools.register({
task: yield* TaskTool.make(sessions, (location, id) =>
AgentV2.Service.pipe(
Effect.flatMap((agents) => agents.get(id)),
Effect.provide(locations.get(location)),
),
),
})
return Service.of({
tools: { register: tools.register },
sessions: {
tool: { register: tools.register },
session: {
create: (input) =>
sessions.create({
id: input.id,
parentID: input.parentID,
agent: input.agent,
model: input.model,
location: input.location,
Expand All @@ -111,7 +123,9 @@ export const layer = Layer.effect(
sessionID: input.sessionID,
prompt: input.prompt,
delivery: input.delivery,
resume: input.resume,
}),
resume: sessions.resume,
messages: (input) =>
sessions.messages({
sessionID: input.sessionID,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/public/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SessionEvent } from "../session/event"
import { SessionInput } from "../session/input"
import { SessionMessage } from "../session/message"
import { Prompt } from "../session/prompt"
import type { SessionRunner } from "../session/runner"
import { Agent } from "./agent"
import { Location } from "./location"
import { Model } from "./model"
Expand Down Expand Up @@ -65,6 +66,7 @@ export { MessageDecodeError }

export interface CreateInput {
readonly id?: ID
readonly parentID?: ID
readonly agent?: Agent.ID
readonly model?: Model.Ref
readonly location: Location.Ref
Expand All @@ -75,6 +77,8 @@ export interface PromptInput {
readonly sessionID: ID
readonly prompt: Prompt
readonly delivery?: Delivery
/** Admit durably without scheduling execution. */
readonly resume?: boolean
}

export interface SwitchModelInput {
Expand Down Expand Up @@ -107,6 +111,8 @@ export interface Interface {
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
/** Explicitly drain one Session and wait for the current execution chain to settle. */
readonly resume: (sessionID: ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly switchModel: (
input: SwitchModelInput,
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export type ListInput = typeof ListInput.Type

type CreateInput = {
id?: SessionSchema.ID
parentID?: SessionSchema.ID
agent?: AgentV2.ID
model?: ModelV2.Ref
location: Location.Ref
Expand Down Expand Up @@ -216,6 +217,7 @@ export const layer = Layer.effect(
slug: Slug.create(),
version: InstallationVersion,
projectID: project.id,
parentID: input.parentID,
directory: input.location.directory,
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
Expand Down
122 changes: 122 additions & 0 deletions packages/core/src/tool/task.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
export * as TaskTool from "./task"

import { ToolFailure } from "@opencode-ai/llm"
import { Cause, Effect, Schema, Scope } from "effect"
import { AgentV2 } from "../agent"
import { Location } from "../location"
import { SessionV2 } from "../session"
import { SessionMessage } from "../session/message"
import { Prompt } from "../session/prompt"
import { Tool } from "./tool"

export const Input = Schema.Struct({
description: Schema.String.annotate({ description: "A short description of the task" }),
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
subagent_type: Schema.String.annotate({ description: "The specialized agent to use" }),
background: Schema.optional(Schema.Boolean).annotate({
description: "Return immediately and notify the parent Session when the task finishes",
}),
})

export const Output = Schema.Struct({
sessionID: SessionV2.ID,
status: Schema.Literals(["running", "completed"]),
output: Schema.String.pipe(Schema.optional),
})

type Sessions = Pick<SessionV2.Interface, "create" | "get" | "interrupt" | "messages" | "prompt" | "resume">

export const make = Effect.fn("TaskTool.make")(function* (
sessions: Sessions,
resolveAgent: (location: Location.Ref, id: AgentV2.ID) => Effect.Effect<AgentV2.Info | undefined>,
) {
const scope = yield* Scope.Scope

return Tool.make({
description:
"Delegate focused work to a specialized child agent. Foreground calls wait for the result; background calls return immediately and notify this Session when complete.",
input: Input,
output: Output,
execute: (parameters, context) =>
Effect.gen(function* () {
const parent = yield* sessions.get(context.sessionID)
const agent = yield* resolveAgent(parent.location, AgentV2.ID.make(parameters.subagent_type))
if (!agent || (agent.mode !== "subagent" && agent.mode !== "all") || agent.hidden)
return yield* new ToolFailure({ message: `Unknown subagent: ${parameters.subagent_type}` })
const child = yield* sessions.create({
parentID: parent.id,
location: parent.location,
agent: agent.id,
model: agent.model ?? parent.model,
})

// TODO: Replace this fresh-child-only composition once Session execution exposes a bounded
// activity/result identity. An admission ID alone cannot correlate a response when one drain
// processes later queued work.
const run = Effect.gen(function* () {
yield* sessions.prompt({
sessionID: child.id,
prompt: new Prompt({ text: parameters.prompt }),
delivery: "steer",
resume: false,
})
yield* sessions.resume(child.id)
const messages = yield* sessions.messages({ sessionID: child.id, order: "desc", limit: 1 })
const assistant = messages.find(
(message): message is SessionMessage.Assistant => message.type === "assistant" && !!message.time.completed,
)
if (!assistant) return ""
return assistant.content
.filter((part): part is SessionMessage.AssistantText => part.type === "text")
.map((part) => part.text)
.join("\n")
})

if (parameters.background !== true) {
const output = yield* run.pipe(
Effect.onInterrupt(() => sessions.interrupt(child.id)),
Effect.mapError((error) => new ToolFailure({ message: `Task failed: ${String(error)}`, error })),
)
return { sessionID: child.id, status: "completed" as const, output }
}

yield* run.pipe(
Effect.matchCauseEffect({
onSuccess: (output) => notify("completed", output),
onFailure: (cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.void : notify("error", String(Cause.squash(cause))),
}),
Effect.tapCause((cause) => Effect.logError("Background task notification failed", Cause.squash(cause))),
Effect.ignore,
Effect.forkIn(scope, { startImmediately: true }),
)
return { sessionID: child.id, status: "running" as const }

function notify(state: "completed" | "error", text: string) {
const tag = state === "completed" ? "task_result" : "task_error"
return sessions.prompt({
sessionID: parent.id,
prompt: new Prompt({
text: `<task id="${child.id}" state="${state}">\n<summary>Background task ${state}: ${parameters.description}</summary>\n<${tag}>\n${text}\n</${tag}>\n</task>`,
}),
delivery: "steer",
})
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to run task: ${String(error)}`, error }),
),
),
toModelOutput: ({ output }) => [
{
type: "text",
text:
output.status === "running"
? `<task id="${output.sessionID}" state="running">\nThe task is working in the background. You will be notified automatically when it finishes.\n</task>`
: `<task id="${output.sessionID}" state="completed">\n<task_result>\n${output.output ?? ""}\n</task_result>\n</task>`,
},
],
})
})
39 changes: 20 additions & 19 deletions packages/core/test/public-opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ describe("public native OpenCode API", () => {
Effect.gen(function* () {
const opencode = yield* OpenCode.Service

expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"])
expect(Object.keys(opencode).sort()).toEqual(["session", "tool"])

expect(Object.keys(opencode.sessions).sort()).toEqual([
expect(Object.keys(opencode.session).sort()).toEqual([
"context",
"create",
"events",
Expand All @@ -25,12 +25,13 @@ describe("public native OpenCode API", () => {
"message",
"messages",
"prompt",
"resume",
"switchModel",
])
expect(Session.ID.create()).toStartWith("ses_")
expect(Session.MessageID.create()).toStartWith("msg_")
expect(yield* opencode.sessions.list()).toBeArray()
yield* opencode.tools.register({
expect(yield* opencode.session.list()).toBeArray()
yield* opencode.tool.register({
public_tool: Tool.make({
description: "Public tool",
input: Schema.Struct({}),
Expand All @@ -52,14 +53,14 @@ describe("public native OpenCode API", () => {
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_available")
const model = ref({ variant: "fast" })
yield* opencode.sessions.create({
yield* opencode.session.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
})

yield* opencode.sessions.switchModel({ sessionID, model })
yield* opencode.session.switchModel({ sessionID, model })

expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
expect((yield* opencode.session.get(sessionID)).model).toEqual(model)
}),
),
),
Expand All @@ -77,27 +78,27 @@ describe("public native OpenCode API", () => {
const opencode = yield* OpenCode.Service
const availableID = Session.ID.make("ses_public_switch_exact_available")
const disabledID = Session.ID.make("ses_public_switch_exact_disabled")
yield* opencode.sessions.create({
yield* opencode.session.create({
id: availableID,
location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }),
})
yield* opencode.sessions.create({
yield* opencode.session.create({
id: disabledID,
location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }),
})

yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
const disabledError = yield* opencode.sessions
yield* opencode.session.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
const disabledError = yield* opencode.session
.switchModel({ sessionID: disabledID, model: ref() })
.pipe(Effect.flip)
const missingError = yield* opencode.sessions
const missingError = yield* opencode.session
.switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) })
.pipe(Effect.flip)

expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError)
expect(missingError).toBeInstanceOf(Session.ModelUnavailableError)
expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" }))
expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined()
expect((yield* opencode.session.get(availableID)).model).toEqual(ref({ variant: "default" }))
expect((yield* opencode.session.get(disabledID)).model).toBeUndefined()
}),
),
),
Expand All @@ -114,18 +115,18 @@ describe("public native OpenCode API", () => {
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_variant")
const selected = ref({ variant: "fast" })
yield* opencode.sessions.create({
yield* opencode.session.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
})
yield* opencode.sessions.switchModel({ sessionID, model: selected })
yield* opencode.session.switchModel({ sessionID, model: selected })

const error = yield* opencode.sessions
const error = yield* opencode.session
.switchModel({ sessionID, model: ref({ variant: "unknown" }) })
.pipe(Effect.flip)

expect(error).toBeInstanceOf(Session.VariantUnavailableError)
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected)
expect((yield* opencode.session.get(sessionID)).model).toEqual(selected)
}),
),
),
Expand All @@ -135,7 +136,7 @@ describe("public native OpenCode API", () => {
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_missing")
const error = yield* opencode.sessions
const error = yield* opencode.session
.switchModel({
sessionID,
model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }),
Expand Down
4 changes: 3 additions & 1 deletion packages/core/test/session-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe("SessionV2.create", () => {
it.effect("stores supplied immutable create attributes", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const parentID = SessionV2.ID.make("ses_parent")
const workspaceID = WorkspaceV2.ID.make("wrk_test")
const model = ModelV2.Ref.make({
id: ModelV2.ID.make("sonnet"),
Expand All @@ -104,10 +105,11 @@ describe("SessionV2.create", () => {
expect(
yield* session.create({
location: Location.Ref.make({ directory: location.directory, workspaceID }),
parentID,
agent: AgentV2.ID.make("build"),
model,
}),
).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
).toMatchObject({ parentID, location: { directory: location.directory, workspaceID }, agent: "build", model })
}),
)

Expand Down
Loading
Loading