Compare commits

...
Author SHA1 Message Date
rekram1-node ee736882de refactor(core): seed subagent history directly 2026-09-07 22:11:00 +00:00
rekram1-node eb2d29d975 feat(core): add subagent context forks 2026-09-07 21:41:16 +00:00
3 changed files with 190 additions and 5 deletions
+27 -2
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { LLMClient } from "@opencode/ai"
import { ListAnchor } from "@opencode/schema/session"
import { and, desc, eq } from "drizzle-orm"
@@ -84,6 +84,7 @@ type CreateBaseInput = {
agent?: Agent.ID
model?: Model.Ref
metadata?: SessionSchema.Metadata
messages?: readonly SessionMessage.Info[]
}
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
@@ -232,6 +233,7 @@ const layer = Layer.effect(
const environments = yield* SessionEnvironment.Service
const sessions = yield* Session.make()
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const result = Service.of({
create: Effect.fn("Session.create")(function* (input) {
@@ -244,6 +246,19 @@ const layer = Layer.effect(
if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = yield* projects.resolve(location.directory)
const messages = (input.messages ?? []).map((message, index) => {
const id = SessionMessage.ID.create()
const encoded = encodeMessage(message)
const { id: _, type, ...data } = encoded
return {
id,
session_id: sessionID,
type,
seq: index + 1,
time_created: DateTime.toEpochMillis(message.time.created),
data,
}
})
const projected = yield* bus
.publish(
SessionEvent.Created,
@@ -268,7 +283,17 @@ const layer = Layer.effect(
}
: undefined,
},
{ location },
{
location,
commit: (seq) =>
messages.length === 0
? Effect.void
: db
.insert(SessionMessageTable)
.values(messages)
.run()
.pipe(Effect.andThen(Bus.reserveSequence(db, sessionID, seq + messages.length)), Effect.orDie),
},
)
.pipe(
Effect.as({ type: "created" } as const),
+68 -2
View File
@@ -8,6 +8,7 @@ import { Config } from "../../config.js"
import { Job } from "../../job.js"
import { Permission } from "../../permission.js"
import { Session } from "../../session.js"
import { SessionMessage } from "../../session/message.js"
import { SessionSchema } from "../../session/schema.js"
import { SubagentCompletion } from "../../session/subagent-completion.js"
import { SubagentJob } from "../../session/subagent-job.js"
@@ -24,10 +25,49 @@ const backgroundResult = (sessionID: SessionSchema.ID) => ({
].join("\n"),
})
const forkMessages = (messages: readonly SessionMessage.Info[], turns: "all" | number) => {
const boundaries = messages.flatMap((message, index) =>
message.type === "user" || (message.type === "synthetic" && message.metadata?.source === "subagent") ? [index] : [],
)
const start = turns === "all" ? 0 : (boundaries.at(-turns) ?? boundaries[0] ?? messages.length)
return messages.slice(start).flatMap((message): SessionMessage.Info[] => {
if (message.type === "user" || message.type === "system" || message.type === "skill") return [message]
if (message.type === "compaction") return message.status === "completed" ? [message] : []
if (
message.type !== "assistant" ||
!message.time.completed ||
!message.finish ||
message.finish === "tool-calls" ||
message.finish === "error"
)
return []
const content = message.content.filter((item) => item.type === "text" && item.text.length > 0)
return content.length > 0
? [
SessionMessage.Assistant.make({
id: message.id,
type: "assistant",
agent: message.agent,
model: message.model,
content,
finish: message.finish,
rawFinish: message.rawFinish,
metadata: message.metadata,
time: message.time,
}),
]
: []
})
}
export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
fork_turns: Schema.optionalKey(Schema.String).annotate({
description:
"Optional number of turns to fork. Defaults to `all`. Use `none`, `all`, or a positive integer string such as `3` to fork only the most recent turns.",
}),
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
description:
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
@@ -46,7 +86,7 @@ export const Output = Schema.Struct({
export const description = [
"Spawns an agent in a child session to work on the specified task.",
"The output includes a sessionID you can pass back later to continue that specific conversation with the subagent.",
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
"New child sessions inherit the parent context by default. Use fork_turns to control how much history is inherited.",
"Foreground (default) runs the subagent to completion and returns its final response.",
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
"Use background only for independent work that can run while you continue elsewhere.",
@@ -79,6 +119,17 @@ export const Plugin = {
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
)
const forkValue = input.fork_turns?.trim().toLowerCase() || "all"
const forkCount = Number(forkValue)
if (
forkValue !== "none" &&
forkValue !== "all" &&
(!/^\d+$/.test(forkValue) || forkCount < 1 || !Number.isSafeInteger(forkCount))
)
return yield* new ToolFailure({
message: `Invalid fork_turns value '${input.fork_turns}'. Expected 'none', 'all', or a positive integer string.`,
})
const forkTurns = forkValue === "none" || forkValue === "all" ? forkValue : forkCount
let current = parent
let depth = 0
while (current.parentID) {
@@ -148,14 +199,29 @@ export const Plugin = {
// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const messages =
existing || forkTurns === "none"
? []
: forkMessages(
yield* sessions
.context(parent.id)
.pipe(
Effect.mapError(
(error) =>
new ToolFailure({ message: `Failed to load parent context: ${parent.id}`, error }),
),
),
forkTurns,
)
const child =
existing ??
(yield* sessions
.create({
parentID: context.sessionID,
title: input.description,
agent: Agent.ID.make(input.agent),
agent: agent.id,
model,
messages,
})
.pipe(
Effect.mapError(
+95 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { TestLLM } from "@opencode/ai/testing"
@@ -433,6 +433,100 @@ describe("SubagentTool", () => {
),
)
it.live("forks all, recent, or no parent turns into a new child", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const time = DateTime.makeUnsafe(1)
const user = (text: string) =>
SessionMessage.User.make({ id: SessionMessage.ID.create(), type: "user", text, time: { created: time } })
const assistant = (text: string, finish: "stop" | "tool-calls", reasoning?: string) =>
SessionMessage.Assistant.make({
id: SessionMessage.ID.create(),
type: "assistant",
agent: Agent.ID.make("build"),
model: parentModel,
content: [
...(reasoning
? [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: reasoning,
time: { created: time, completed: time },
}),
]
: []),
SessionMessage.AssistantText.make({ type: "text", text }),
],
finish,
cost: Money.USD.make(1),
tokens,
time: { created: time, completed: time },
})
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* Session.Service
const parent = yield* sessions.create({
location,
model: parentModel,
messages: [
user("old task"),
assistant("old final answer", "stop", "private reasoning"),
user("recent task"),
assistant("working on it", "tool-calls"),
user("current task"),
],
})
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
const run = (id: string, fork_turns?: string) =>
executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call" as const,
id,
name: SubagentTool.name,
input: {
agent: "reviewer",
description: "review",
prompt: "review this",
...(fork_turns === undefined ? {} : { fork_turns }),
},
},
})
const all = yield* run("call-fork-all")
expect((yield* sessions.context(outputSessionID(all.metadata))).slice(0, -1)).toMatchObject([
{ type: "user", text: "old task" },
{ type: "assistant", content: [{ type: "text", text: "old final answer" }] },
{ type: "user", text: "recent task" },
{ type: "user", text: "current task" },
])
const recent = yield* run("call-fork-recent", "2")
expect((yield* sessions.context(outputSessionID(recent.metadata))).slice(0, -1)).toMatchObject([
{ type: "user", text: "recent task" },
{ type: "user", text: "current task" },
])
const none = yield* run("call-fork-none", "none")
expect(yield* sessions.context(outputSessionID(none.metadata))).toMatchObject([
{ type: "assistant", content: [{ type: "text", text: childText }] },
])
expect(yield* run("call-fork-invalid", "0")).toMatchObject({
status: "error",
error: { message: expect.stringContaining("Invalid fork_turns value '0'") },
})
}),
),
),
)
it.live("continues an existing child session", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),