Skip to content

Commit 9f943cb

Browse files
Apply PR #12755: feat(session): add handoff functionality and agent updates
2 parents bec424b + 89ca798 commit 9f943cb

40 files changed

+3887
-11
lines changed

packages/opencode/src/agent/agent.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,18 @@ export namespace Agent {
184184
),
185185
prompt: PROMPT_TITLE,
186186
},
187+
handoff: {
188+
name: "handoff",
189+
mode: "primary",
190+
options: {},
191+
native: true,
192+
hidden: true,
193+
temperature: 0.5,
194+
permission: PermissionNext.fromConfig({
195+
"*": "allow",
196+
}),
197+
prompt: "none",
198+
},
187199
summary: {
188200
name: "summary",
189201
mode: "primary",

packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ function init() {
8383
},
8484
slashes() {
8585
return visibleOptions().flatMap((option) => {
86+
if (option.disabled) return []
8687
const slash = option.slash
8788
if (!slash) return []
8889
return {

packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
99

1010
export type PromptInfo = {
1111
input: string
12-
mode?: "normal" | "shell"
12+
mode?: "normal" | "shell" | "handoff"
1313
parts: (
1414
| Omit<FilePart, "id" | "messageID" | "sessionID">
1515
| Omit<AgentPart, "id" | "messageID" | "sessionID">

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export function Prompt(props: PromptProps) {
120120

121121
const [store, setStore] = createStore<{
122122
prompt: PromptInfo
123-
mode: "normal" | "shell"
123+
mode: "normal" | "shell" | "handoff"
124124
extmarkToPartIndex: Map<number, number>
125125
interrupt: number
126126
placeholder: number
@@ -349,6 +349,20 @@ export function Prompt(props: PromptProps) {
349349
))
350350
},
351351
},
352+
{
353+
title: "Handoff",
354+
value: "prompt.handoff",
355+
disabled: props.sessionID === undefined,
356+
category: "Prompt",
357+
slash: {
358+
name: "handoff",
359+
},
360+
onSelect: () => {
361+
input.clear()
362+
setStore("mode", "handoff")
363+
setStore("prompt", { input: "", parts: [] })
364+
},
365+
},
352366
]
353367
})
354368

@@ -526,17 +540,45 @@ export function Prompt(props: PromptProps) {
526540
async function submit() {
527541
if (props.disabled) return
528542
if (autocomplete?.visible) return
543+
const selectedModel = local.model.current()
544+
if (!selectedModel) {
545+
promptModelWarning()
546+
return
547+
}
548+
549+
if (store.mode === "handoff") {
550+
const result = await sdk.client.session.handoff({
551+
sessionID: props.sessionID!,
552+
goal: store.prompt.input,
553+
model: {
554+
providerID: selectedModel.providerID,
555+
modelID: selectedModel.modelID,
556+
},
557+
})
558+
if (result.data) {
559+
route.navigate({
560+
type: "home",
561+
initialPrompt: {
562+
input: result.data.text,
563+
parts:
564+
result.data.files.map((file) => ({
565+
type: "file",
566+
url: file,
567+
filename: file,
568+
mime: "text/plain",
569+
})) ?? [],
570+
},
571+
})
572+
}
573+
return
574+
}
575+
529576
if (!store.prompt.input) return
530577
const trimmed = store.prompt.input.trim()
531578
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
532579
exit()
533580
return
534581
}
535-
const selectedModel = local.model.current()
536-
if (!selectedModel) {
537-
promptModelWarning()
538-
return
539-
}
540582
const sessionID = props.sessionID
541583
? props.sessionID
542584
: await (async () => {
@@ -737,6 +779,7 @@ export function Prompt(props: PromptProps) {
737779
const highlight = createMemo(() => {
738780
if (keybind.leader) return theme.border
739781
if (store.mode === "shell") return theme.primary
782+
if (store.mode === "handoff") return theme.warning
740783
return local.agent.color(local.agent.current().name)
741784
})
742785

@@ -748,6 +791,7 @@ export function Prompt(props: PromptProps) {
748791
})
749792

750793
const placeholderText = createMemo(() => {
794+
if (store.mode === "handoff") return "Goal for the new session"
751795
if (props.sessionID) return undefined
752796
if (store.mode === "shell") {
753797
const example = SHELL_PLACEHOLDERS[store.placeholder % SHELL_PLACEHOLDERS.length]
@@ -875,7 +919,7 @@ export function Prompt(props: PromptProps) {
875919
e.preventDefault()
876920
return
877921
}
878-
if (store.mode === "shell") {
922+
if (store.mode === "shell" || store.mode === "handoff") {
879923
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
880924
setStore("mode", "normal")
881925
e.preventDefault()
@@ -996,7 +1040,11 @@ export function Prompt(props: PromptProps) {
9961040
/>
9971041
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
9981042
<text fg={highlight()}>
999-
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
1043+
<Switch>
1044+
<Match when={store.mode === "normal"}>{Locale.titlecase(local.agent.current().name)}</Match>
1045+
<Match when={store.mode === "shell"}>Shell</Match>
1046+
<Match when={store.mode === "handoff"}>Handoff</Match>
1047+
</Switch>
10001048
</text>
10011049
<Show when={store.mode === "normal"}>
10021050
<box flexDirection="row" gap={1}>
@@ -1143,6 +1191,11 @@ export function Prompt(props: PromptProps) {
11431191
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
11441192
</text>
11451193
</Match>
1194+
<Match when={store.mode === "handoff"}>
1195+
<text fg={theme.text}>
1196+
esc <span style={{ fg: theme.textMuted }}>exit handoff mode</span>
1197+
</text>
1198+
</Match>
11461199
</Switch>
11471200
</box>
11481201
</Show>

packages/opencode/src/cli/cmd/tui/context/route.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createStore } from "solid-js/store"
1+
import { createStore, reconcile } from "solid-js/store"
22
import { createSimpleContext } from "./helper"
33
import type { PromptInfo } from "../component/prompt/history"
44

@@ -32,7 +32,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
3232
},
3333
navigate(route: Route) {
3434
console.log("navigate", route)
35-
setStore(route)
35+
setStore(reconcile(route))
3636
},
3737
}
3838
},

packages/opencode/src/server/routes/session.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { MessageV2 } from "../../session/message-v2"
77
import { SessionPrompt } from "../../session/prompt"
88
import { SessionCompaction } from "../../session/compaction"
99
import { SessionRevert } from "../../session/revert"
10+
import { SessionHandoff } from "../../session/handoff"
1011
import { SessionStatus } from "@/session/status"
1112
import { SessionSummary } from "@/session/summary"
1213
import { Todo } from "../../session/todo"
@@ -932,5 +933,41 @@ export const SessionRoutes = lazy(() =>
932933
})
933934
return c.json(true)
934935
},
936+
)
937+
.post(
938+
"/:sessionID/handoff",
939+
describeRoute({
940+
summary: "Handoff session",
941+
description: "Extract context and relevant files for another agent to continue the conversation.",
942+
operationId: "session.handoff",
943+
responses: {
944+
200: {
945+
description: "Handoff data extracted",
946+
content: {
947+
"application/json": {
948+
schema: resolver(z.object({ text: z.string(), files: z.string().array() })),
949+
},
950+
},
951+
},
952+
...errors(400, 404),
953+
},
954+
}),
955+
validator(
956+
"param",
957+
z.object({
958+
sessionID: z.string().meta({ description: "Session ID" }),
959+
}),
960+
),
961+
validator("json", SessionHandoff.handoff.schema.omit({ sessionID: true })),
962+
async (c) => {
963+
const params = c.req.valid("param")
964+
const body = c.req.valid("json")
965+
const result = await SessionHandoff.handoff({
966+
sessionID: params.sessionID,
967+
model: body.model,
968+
goal: body.goal,
969+
})
970+
return c.json(result)
971+
},
935972
),
936973
)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { fn } from "@/util/fn"
2+
import z from "zod"
3+
import { MessageV2 } from "./message-v2"
4+
import { LLM } from "./llm"
5+
import { Agent } from "@/agent/agent"
6+
import { Provider } from "@/provider/provider"
7+
import { iife } from "@/util/iife"
8+
import { Identifier } from "@/id/id"
9+
import PROMPT_HANDOFF from "./prompt/handoff.txt"
10+
import { type Tool } from "ai"
11+
import { SessionStatus } from "./status"
12+
import { defer } from "@/util/defer"
13+
14+
export namespace SessionHandoff {
15+
const HandoffTool: Tool = {
16+
description:
17+
"A tool to extract relevant information from the thread and select relevant files for another agent to continue the conversation. Use this tool to identify the most important context and files needed.",
18+
inputSchema: z.object({
19+
text: z.string().describe(PROMPT_HANDOFF),
20+
files: z
21+
.string()
22+
.array()
23+
.describe(
24+
[
25+
"An array of file or directory paths (workspace-relative) that are relevant to accomplishing the goal.",
26+
"",
27+
'IMPORTANT: Return as a JSON array of strings, e.g., ["packages/core/src/session/message-v2.ts", "packages/core/src/session/prompt/handoff.txt"]',
28+
"",
29+
"Rules:",
30+
"- Maximum 10 files. Only include the most critical files needed for the task.",
31+
"- You can include directories if multiple files from that directory are needed",
32+
"- Prioritize by importance and relevance. PUT THE MOST IMPORTANT FILES FIRST.",
33+
'- Return workspace-relative paths (e.g., "packages/core/src/session/message-v2.ts")',
34+
"- Do not use absolute paths or invent files",
35+
].join("\n"),
36+
),
37+
}),
38+
async execute(_args, _ctx) {
39+
return {}
40+
},
41+
}
42+
43+
export const handoff = fn(
44+
z.object({
45+
sessionID: z.string(),
46+
model: z.object({ providerID: z.string(), modelID: z.string() }),
47+
goal: z.string().optional(),
48+
}),
49+
async (input) => {
50+
SessionStatus.set(input.sessionID, { type: "busy" })
51+
using _ = defer(() => SessionStatus.set(input.sessionID, { type: "idle" }))
52+
const messages = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID))
53+
const agent = await Agent.get("handoff")
54+
const model = await iife(async () => {
55+
if (agent.model) return Provider.getModel(agent.model.providerID, agent.model.modelID)
56+
const small = await Provider.getSmallModel(input.model.providerID)
57+
if (small) return small
58+
return Provider.getModel(input.model.providerID, input.model.modelID)
59+
})
60+
const user = {
61+
info: {
62+
model: {
63+
providerID: model.providerID,
64+
modelID: model.id,
65+
},
66+
agent: agent.name,
67+
sessionID: input.sessionID,
68+
id: Identifier.ascending("user"),
69+
role: "user",
70+
time: {
71+
created: Date.now(),
72+
},
73+
} satisfies MessageV2.User,
74+
parts: [
75+
{
76+
type: "text",
77+
text: PROMPT_HANDOFF + "\n\nMy request:\n" + (input.goal ?? "general summarization"),
78+
id: Identifier.ascending("part"),
79+
sessionID: input.sessionID,
80+
messageID: Identifier.ascending("message"),
81+
},
82+
] satisfies MessageV2.TextPart[],
83+
} satisfies MessageV2.WithParts
84+
const abort = new AbortController()
85+
const stream = await LLM.stream({
86+
agent,
87+
messages: MessageV2.toModelMessages([...messages, user], model),
88+
sessionID: input.sessionID,
89+
abort: abort.signal,
90+
model,
91+
system: [],
92+
small: true,
93+
user: user.info,
94+
output: "tool",
95+
tools: {
96+
handoff: HandoffTool,
97+
},
98+
})
99+
100+
const [result] = await stream.toolCalls
101+
if (!result) throw new Error("Handoff tool did not return a result")
102+
return result.input
103+
},
104+
)
105+
}

packages/opencode/src/session/llm.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export namespace LLM {
3838
small?: boolean
3939
tools: Record<string, Tool>
4040
retries?: number
41+
output?: "tool"
4142
toolChoice?: "auto" | "required" | "none"
4243
}
4344

@@ -209,6 +210,7 @@ export namespace LLM {
209210
toolChoice: input.toolChoice,
210211
maxOutputTokens,
211212
abortSignal: input.abort,
213+
toolChoice: input.output === "tool" ? "required" : undefined,
212214
headers: {
213215
...(input.model.providerID.startsWith("opencode")
214216
? {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Extract relevant context from the conversation above for continuing this work. Write from my perspective (first person: "I did...", "I told you...").
2+
3+
Consider what would be useful to know based on my request below. Questions that might be relevant:
4+
5+
- What did I just do or implement?
6+
- What instructions did I already give you which are still relevant (e.g. follow patterns in the codebase)?
7+
- What files did I already tell you that's important or that I am working on (and should continue working on)?
8+
- Did I provide a plan or spec that should be included?
9+
- What did I already tell you that's important (certain libraries, patterns, constraints, preferences)?
10+
- What important technical details did I discover (APIs, methods, patterns)?
11+
- What caveats, limitations, or open questions did I find?
12+
13+
Extract what matters for the specific request below. Don't answer questions that aren't relevant. Pick an appropriate length based on the complexity of the request.
14+
15+
Focus on capabilities and behavior, not file-by-file changes. Avoid excessive implementation details (variable names, storage keys, constants) unless critical.
16+
17+
Format: Plain text with bullets. No markdown headers, no bold/italic, no code fences. Use workspace-relative paths for files.

0 commit comments

Comments
 (0)