mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-18 16:34:18 +01:00
Refactor agent loop (#4412)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ dist
|
|||||||
.turbo
|
.turbo
|
||||||
**/.serena
|
**/.serena
|
||||||
.serena/
|
.serena/
|
||||||
|
refs
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: Git commit and push
|
description: Git commit and push
|
||||||
|
subtask: true
|
||||||
---
|
---
|
||||||
|
|
||||||
commit and push
|
commit and push
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"plugin": ["opencode-openai-codex-auth"]
|
|
||||||
}
|
|
||||||
11
.opencode/opencode.jsonc
Normal file
11
.opencode/opencode.jsonc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"plugin": ["opencode-openai-codex-auth"],
|
||||||
|
"provider": {
|
||||||
|
"opencode": {
|
||||||
|
"options": {
|
||||||
|
// "baseURL": "http://localhost:8080"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { EOL } from "os"
|
|||||||
import { File } from "../../../file"
|
import { File } from "../../../file"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
|
import { Ripgrep } from "@/file/ripgrep"
|
||||||
|
|
||||||
const FileSearchCommand = cmd({
|
const FileSearchCommand = cmd({
|
||||||
command: "search <query>",
|
command: "search <query>",
|
||||||
@@ -62,6 +63,20 @@ const FileListCommand = cmd({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const FileTreeCommand = cmd({
|
||||||
|
command: "tree [dir]",
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs.positional("dir", {
|
||||||
|
type: "string",
|
||||||
|
description: "Directory to tree",
|
||||||
|
default: process.cwd(),
|
||||||
|
}),
|
||||||
|
async handler(args) {
|
||||||
|
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
|
||||||
|
console.log(files)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const FileCommand = cmd({
|
export const FileCommand = cmd({
|
||||||
command: "file",
|
command: "file",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
@@ -70,6 +85,7 @@ export const FileCommand = cmd({
|
|||||||
.command(FileStatusCommand)
|
.command(FileStatusCommand)
|
||||||
.command(FileListCommand)
|
.command(FileListCommand)
|
||||||
.command(FileSearchCommand)
|
.command(FileSearchCommand)
|
||||||
|
.command(FileTreeCommand)
|
||||||
.demandCommand(),
|
.demandCommand(),
|
||||||
async handler() {},
|
async handler() {},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
LspStatus,
|
LspStatus,
|
||||||
McpStatus,
|
McpStatus,
|
||||||
FormatterStatus,
|
FormatterStatus,
|
||||||
|
SessionStatus,
|
||||||
} from "@opencode-ai/sdk"
|
} from "@opencode-ai/sdk"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
@@ -33,6 +34,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
config: Config
|
config: Config
|
||||||
session: Session[]
|
session: Session[]
|
||||||
|
session_status: {
|
||||||
|
[sessionID: string]: SessionStatus
|
||||||
|
}
|
||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: Snapshot.FileDiff[]
|
[sessionID: string]: Snapshot.FileDiff[]
|
||||||
}
|
}
|
||||||
@@ -58,6 +62,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
command: [],
|
command: [],
|
||||||
provider: [],
|
provider: [],
|
||||||
session: [],
|
session: [],
|
||||||
|
session_status: {},
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
todo: {},
|
todo: {},
|
||||||
message: {},
|
message: {},
|
||||||
@@ -140,6 +145,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case "session.status": {
|
||||||
|
setStore("session_status", event.properties.sessionID, event.properties.status)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const messages = store.message[event.properties.info.sessionID]
|
const messages = store.message[event.properties.info.sessionID]
|
||||||
if (!messages) {
|
if (!messages) {
|
||||||
@@ -240,6 +251,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
|
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
|
||||||
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
|
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
|
||||||
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
|
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
|
||||||
|
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
setStore("status", "complete")
|
setStore("status", "complete")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { useTheme } from "@tui/context/theme"
|
|||||||
import {
|
import {
|
||||||
BoxRenderable,
|
BoxRenderable,
|
||||||
ScrollBoxRenderable,
|
ScrollBoxRenderable,
|
||||||
TextAttributes,
|
|
||||||
addDefaultParsers,
|
addDefaultParsers,
|
||||||
MacOSScrollAccel,
|
MacOSScrollAccel,
|
||||||
type ScrollAcceleration,
|
type ScrollAcceleration,
|
||||||
@@ -65,7 +64,6 @@ import { Editor } from "../../util/editor"
|
|||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
import { LSP } from "@/lsp/index.ts"
|
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
@@ -101,7 +99,12 @@ export function Session() {
|
|||||||
const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
|
const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
|
||||||
|
|
||||||
const pending = createMemo(() => {
|
const pending = createMemo(() => {
|
||||||
return messages().findLast((x) => x.role === "assistant" && !x.time?.completed)?.id
|
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastUserMessage = createMemo(() => {
|
||||||
|
const p = pending()
|
||||||
|
return messages().findLast((x) => x.role === "user" && (!p || x.id < p)) as UserMessage
|
||||||
})
|
})
|
||||||
|
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
@@ -801,7 +804,7 @@ export function Session() {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={message.role === "assistant"}>
|
<Match when={message.role === "assistant"}>
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
last={index() === messages().length - 1}
|
last={pending() === message.id}
|
||||||
message={message as AssistantMessage}
|
message={message as AssistantMessage}
|
||||||
parts={sync.data.part[message.id] ?? []}
|
parts={sync.data.part[message.id] ?? []}
|
||||||
/>
|
/>
|
||||||
@@ -856,64 +859,84 @@ function UserMessage(props: {
|
|||||||
const queued = createMemo(() => props.pending && props.message.id > props.pending)
|
const queued = createMemo(() => props.pending && props.message.id > props.pending)
|
||||||
const color = createMemo(() => (queued() ? theme.accent : theme.secondary))
|
const color = createMemo(() => (queued() ? theme.accent : theme.secondary))
|
||||||
|
|
||||||
|
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={text()}>
|
<>
|
||||||
<box
|
<Show when={text()}>
|
||||||
id={props.message.id}
|
<box
|
||||||
onMouseOver={() => {
|
id={props.message.id}
|
||||||
setHover(true)
|
onMouseOver={() => {
|
||||||
}}
|
setHover(true)
|
||||||
onMouseOut={() => {
|
}}
|
||||||
setHover(false)
|
onMouseOut={() => {
|
||||||
}}
|
setHover(false)
|
||||||
onMouseUp={props.onMouseUp}
|
}}
|
||||||
border={["left"]}
|
onMouseUp={props.onMouseUp}
|
||||||
paddingTop={1}
|
border={["left"]}
|
||||||
paddingBottom={1}
|
paddingTop={1}
|
||||||
paddingLeft={2}
|
paddingBottom={1}
|
||||||
marginTop={props.index === 0 ? 0 : 1}
|
paddingLeft={2}
|
||||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
marginTop={props.index === 0 ? 0 : 1}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||||
borderColor={color()}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
flexShrink={0}
|
borderColor={color()}
|
||||||
>
|
flexShrink={0}
|
||||||
<text fg={theme.text}>{text()?.text}</text>
|
>
|
||||||
<Show when={files().length}>
|
<text fg={theme.text}>{text()?.text}</text>
|
||||||
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
<Show when={files().length}>
|
||||||
<For each={files()}>
|
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
||||||
{(file) => {
|
<For each={files()}>
|
||||||
const bg = createMemo(() => {
|
{(file) => {
|
||||||
if (file.mime.startsWith("image/")) return theme.accent
|
const bg = createMemo(() => {
|
||||||
if (file.mime === "application/pdf") return theme.primary
|
if (file.mime.startsWith("image/")) return theme.accent
|
||||||
return theme.secondary
|
if (file.mime === "application/pdf") return theme.primary
|
||||||
})
|
return theme.secondary
|
||||||
return (
|
})
|
||||||
<text fg={theme.text}>
|
return (
|
||||||
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
|
<text fg={theme.text}>
|
||||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
|
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
|
||||||
</text>
|
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
|
||||||
)
|
</text>
|
||||||
}}
|
)
|
||||||
</For>
|
}}
|
||||||
</box>
|
</For>
|
||||||
</Show>
|
</box>
|
||||||
<text fg={theme.text}>
|
|
||||||
{sync.data.config.username ?? "You"}{" "}
|
|
||||||
<Show
|
|
||||||
when={queued()}
|
|
||||||
fallback={<span style={{ fg: theme.textMuted }}>({Locale.time(props.message.time.created)})</span>}
|
|
||||||
>
|
|
||||||
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
|
|
||||||
</Show>
|
</Show>
|
||||||
</text>
|
<text fg={theme.text}>
|
||||||
</box>
|
{sync.data.config.username ?? "You"}{" "}
|
||||||
</Show>
|
<Show
|
||||||
|
when={queued()}
|
||||||
|
fallback={<span style={{ fg: theme.textMuted }}>({Locale.time(props.message.time.created)})</span>}
|
||||||
|
>
|
||||||
|
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
|
||||||
|
</Show>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={compaction()}>
|
||||||
|
<box
|
||||||
|
marginTop={1}
|
||||||
|
border={["top"]}
|
||||||
|
title=" Compaction "
|
||||||
|
titleAlignment="center"
|
||||||
|
borderColor={theme.borderActive}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
|
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const sync = useSync()
|
||||||
|
const status = createMemo(
|
||||||
|
() =>
|
||||||
|
sync.data.session_status[props.message.sessionID] ?? {
|
||||||
|
type: "idle",
|
||||||
|
},
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<For each={props.parts}>
|
<For each={props.parts}>
|
||||||
@@ -945,23 +968,15 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
|||||||
<text fg={theme.textMuted}>{props.message.error?.data.message}</text>
|
<text fg={theme.textMuted}>{props.message.error?.data.message}</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show when={props.last && status().type !== "idle"}>
|
||||||
when={
|
<box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}>
|
||||||
!props.message.time.completed ||
|
|
||||||
(props.last && props.parts.some((item) => item.type === "step-finish" && item.reason === "tool-calls"))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<box
|
|
||||||
paddingLeft={2}
|
|
||||||
marginTop={1}
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
border={["left"]}
|
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
|
||||||
borderColor={theme.backgroundElement}
|
|
||||||
>
|
|
||||||
<text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
|
<text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
|
||||||
<Shimmer text={`${props.message.modelID}`} color={theme.text} />
|
<Shimmer text={props.message.modelID} color={theme.text} />
|
||||||
|
<Show when={status().type === "retry"}>
|
||||||
|
<text fg={theme.error}>
|
||||||
|
{(status() as any).message} [attempt #{(status() as any).attempt}]
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { lazy } from "../util/lazy"
|
|||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
|
||||||
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
|
||||||
export namespace Ripgrep {
|
export namespace Ripgrep {
|
||||||
|
const log = Log.create({ service: "ripgrep" })
|
||||||
const Stats = z.object({
|
const Stats = z.object({
|
||||||
elapsed: z.object({
|
elapsed: z.object({
|
||||||
secs: z.number(),
|
secs: z.number(),
|
||||||
@@ -254,6 +256,7 @@ export namespace Ripgrep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function tree(input: { cwd: string; limit?: number }) {
|
export async function tree(input: { cwd: string; limit?: number }) {
|
||||||
|
log.info("tree", input)
|
||||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd }))
|
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd }))
|
||||||
interface Node {
|
interface Node {
|
||||||
path: string[]
|
path: string[]
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { Global } from "../global"
|
|||||||
import { ProjectRoute } from "./project"
|
import { ProjectRoute } from "./project"
|
||||||
import { ToolRegistry } from "../tool/registry"
|
import { ToolRegistry } from "../tool/registry"
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||||
import { SessionLock } from "../session/lock"
|
|
||||||
import { SessionPrompt } from "../session/prompt"
|
import { SessionPrompt } from "../session/prompt"
|
||||||
import { SessionCompaction } from "../session/compaction"
|
import { SessionCompaction } from "../session/compaction"
|
||||||
import { SessionRevert } from "../session/revert"
|
import { SessionRevert } from "../session/revert"
|
||||||
@@ -41,6 +40,7 @@ import { TuiEvent } from "@/cli/cmd/tui/event"
|
|||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
|
import { SessionStatus } from "@/session/status"
|
||||||
|
|
||||||
const ERRORS = {
|
const ERRORS = {
|
||||||
400: {
|
400: {
|
||||||
@@ -367,6 +367,28 @@ export namespace Server {
|
|||||||
return c.json(sessions)
|
return c.json(sessions)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.get(
|
||||||
|
"/session/status",
|
||||||
|
describeRoute({
|
||||||
|
description: "Get session status",
|
||||||
|
operationId: "session.status",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Get session status",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.record(z.string(), SessionStatus.Info)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const result = SessionStatus.list()
|
||||||
|
return c.json(result)
|
||||||
|
},
|
||||||
|
)
|
||||||
.get(
|
.get(
|
||||||
"/session/:id",
|
"/session/:id",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -637,7 +659,8 @@ export namespace Server {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(SessionLock.abort(c.req.valid("param").id))
|
SessionPrompt.cancel(c.req.valid("param").id)
|
||||||
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
@@ -771,7 +794,14 @@ export namespace Server {
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const id = c.req.valid("param").id
|
const id = c.req.valid("param").id
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
await SessionCompaction.run({ ...body, sessionID: id })
|
await SessionCompaction.create({
|
||||||
|
sessionID: id,
|
||||||
|
model: {
|
||||||
|
providerID: body.providerID,
|
||||||
|
modelID: body.modelID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await SessionPrompt.loop(id)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { streamText, type ModelMessage, type StreamTextResult, type Tool as AITool } from "ai"
|
import { streamText, type ModelMessage } from "ai"
|
||||||
import { Session } from "."
|
import { Session } from "."
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Provider } from "../provider/provider"
|
import { Provider } from "../provider/provider"
|
||||||
import { defer } from "../util/defer"
|
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
@@ -13,10 +12,9 @@ import { SessionPrompt } from "./prompt"
|
|||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Token } from "../util/token"
|
import { Token } from "../util/token"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { SessionLock } from "./lock"
|
|
||||||
import { ProviderTransform } from "@/provider/transform"
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
import { SessionRetry } from "./retry"
|
import { SessionProcessor } from "./processor"
|
||||||
import { Config } from "@/config/config"
|
import { fn } from "@/util/fn"
|
||||||
|
|
||||||
export namespace SessionCompaction {
|
export namespace SessionCompaction {
|
||||||
const log = Log.create({ service: "session.compaction" })
|
const log = Log.create({ service: "session.compaction" })
|
||||||
@@ -42,7 +40,6 @@ export namespace SessionCompaction {
|
|||||||
|
|
||||||
export const PRUNE_MINIMUM = 20_000
|
export const PRUNE_MINIMUM = 20_000
|
||||||
export const PRUNE_PROTECT = 40_000
|
export const PRUNE_PROTECT = 40_000
|
||||||
const MAX_RETRIES = 10
|
|
||||||
|
|
||||||
// goes backwards through parts until there are 40_000 tokens worth of tool
|
// goes backwards through parts until there are 40_000 tokens worth of tool
|
||||||
// calls. then erases output of previous tool calls. idea is to throw away old
|
// calls. then erases output of previous tool calls. idea is to throw away old
|
||||||
@@ -87,38 +84,29 @@ export namespace SessionCompaction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function run(input: { sessionID: string; providerID: string; modelID: string; signal?: AbortSignal }) {
|
export async function process(input: {
|
||||||
if (!input.signal) SessionLock.assertUnlocked(input.sessionID)
|
parentID: string
|
||||||
await using lock = input.signal === undefined ? SessionLock.acquire({ sessionID: input.sessionID }) : undefined
|
messages: MessageV2.WithParts[]
|
||||||
const signal = input.signal ?? lock!.signal
|
sessionID: string
|
||||||
|
model: {
|
||||||
await Session.update(input.sessionID, (draft) => {
|
providerID: string
|
||||||
draft.time.compacting = Date.now()
|
modelID: string
|
||||||
})
|
}
|
||||||
await using _ = defer(async () => {
|
abort: AbortSignal
|
||||||
await Session.update(input.sessionID, (draft) => {
|
}) {
|
||||||
draft.time.compacting = undefined
|
const model = await Provider.getModel(input.model.providerID, input.model.modelID)
|
||||||
})
|
const system = [...SystemPrompt.summarize(model.providerID)]
|
||||||
})
|
|
||||||
const toSummarize = await MessageV2.filterCompacted(MessageV2.stream(input.sessionID))
|
|
||||||
const model = await Provider.getModel(input.providerID, input.modelID)
|
|
||||||
const system = [
|
|
||||||
...SystemPrompt.summarize(model.providerID),
|
|
||||||
...(await SystemPrompt.environment()),
|
|
||||||
...(await SystemPrompt.custom()),
|
|
||||||
]
|
|
||||||
|
|
||||||
const msg = (await Session.updateMessage({
|
const msg = (await Session.updateMessage({
|
||||||
id: Identifier.ascending("message"),
|
id: Identifier.ascending("message"),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parentID: toSummarize.findLast((m) => m.info.role === "user")?.info.id!,
|
parentID: input.parentID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
mode: "build",
|
mode: "build",
|
||||||
|
summary: true,
|
||||||
path: {
|
path: {
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
root: Instance.worktree,
|
root: Instance.worktree,
|
||||||
},
|
},
|
||||||
summary: true,
|
|
||||||
cost: 0,
|
cost: 0,
|
||||||
tokens: {
|
tokens: {
|
||||||
output: 0,
|
output: 0,
|
||||||
@@ -126,37 +114,27 @@ export namespace SessionCompaction {
|
|||||||
reasoning: 0,
|
reasoning: 0,
|
||||||
cache: { read: 0, write: 0 },
|
cache: { read: 0, write: 0 },
|
||||||
},
|
},
|
||||||
modelID: input.modelID,
|
modelID: input.model.modelID,
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
time: {
|
time: {
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
},
|
},
|
||||||
})) as MessageV2.Assistant
|
})) as MessageV2.Assistant
|
||||||
|
const processor = SessionProcessor.create({
|
||||||
const part = (await Session.updatePart({
|
assistantMessage: msg,
|
||||||
type: "text",
|
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
messageID: msg.id,
|
providerID: input.model.providerID,
|
||||||
id: Identifier.ascending("part"),
|
model: model.info,
|
||||||
text: "",
|
abort: input.abort,
|
||||||
time: {
|
})
|
||||||
start: Date.now(),
|
const result = await processor.process(() =>
|
||||||
},
|
|
||||||
})) as MessageV2.TextPart
|
|
||||||
|
|
||||||
const doStream = () =>
|
|
||||||
streamText({
|
streamText({
|
||||||
// set to 0, we handle loop
|
// set to 0, we handle loop
|
||||||
maxRetries: 0,
|
maxRetries: 0,
|
||||||
model: model.language,
|
model: model.language,
|
||||||
providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, model.info.options),
|
providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, model.info.options),
|
||||||
headers: model.info.headers,
|
headers: model.info.headers,
|
||||||
abortSignal: signal,
|
abortSignal: input.abort,
|
||||||
onError(error) {
|
|
||||||
log.error("stream error", {
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
tools: model.info.tool_call ? {} : undefined,
|
tools: model.info.tool_call ? {} : undefined,
|
||||||
messages: [
|
messages: [
|
||||||
...system.map(
|
...system.map(
|
||||||
@@ -165,7 +143,7 @@ export namespace SessionCompaction {
|
|||||||
content: x,
|
content: x,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
...MessageV2.toModelMessage(toSummarize),
|
...MessageV2.toModelMessage(input.messages),
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [
|
content: [
|
||||||
@@ -176,168 +154,60 @@ export namespace SessionCompaction {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
// TODO: reduce duplication between compaction.ts & prompt.ts
|
if (result === "continue") {
|
||||||
const process = async (
|
const continueMsg = await Session.updateMessage({
|
||||||
stream: StreamTextResult<Record<string, AITool>, never>,
|
id: Identifier.ascending("message"),
|
||||||
retries: { count: number; max: number },
|
role: "user",
|
||||||
) => {
|
|
||||||
let shouldRetry = false
|
|
||||||
try {
|
|
||||||
for await (const value of stream.fullStream) {
|
|
||||||
signal.throwIfAborted()
|
|
||||||
switch (value.type) {
|
|
||||||
case "text-delta":
|
|
||||||
part.text += value.text
|
|
||||||
if (value.providerMetadata) part.metadata = value.providerMetadata
|
|
||||||
if (part.text)
|
|
||||||
await Session.updatePart({
|
|
||||||
part,
|
|
||||||
delta: value.text,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
case "text-end": {
|
|
||||||
part.text = part.text.trimEnd()
|
|
||||||
part.time = {
|
|
||||||
start: Date.now(),
|
|
||||||
end: Date.now(),
|
|
||||||
}
|
|
||||||
if (value.providerMetadata) part.metadata = value.providerMetadata
|
|
||||||
await Session.updatePart(part)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
case "finish-step": {
|
|
||||||
const usage = Session.getUsage({
|
|
||||||
model: model.info,
|
|
||||||
usage: value.usage,
|
|
||||||
metadata: value.providerMetadata,
|
|
||||||
})
|
|
||||||
msg.cost += usage.cost
|
|
||||||
msg.tokens = usage.tokens
|
|
||||||
await Session.updateMessage(msg)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
case "error":
|
|
||||||
throw value.error
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log.error("compaction error", {
|
|
||||||
error: e,
|
|
||||||
})
|
|
||||||
const error = MessageV2.fromError(e, { providerID: input.providerID })
|
|
||||||
if (retries.count < retries.max && MessageV2.APIError.isInstance(error) && error.data.isRetryable) {
|
|
||||||
shouldRetry = true
|
|
||||||
await Session.updatePart({
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
messageID: msg.id,
|
|
||||||
sessionID: msg.sessionID,
|
|
||||||
type: "retry",
|
|
||||||
attempt: retries.count + 1,
|
|
||||||
time: {
|
|
||||||
created: Date.now(),
|
|
||||||
},
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
msg.error = error
|
|
||||||
Bus.publish(Session.Event.Error, {
|
|
||||||
sessionID: msg.sessionID,
|
|
||||||
error: msg.error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = await MessageV2.parts(msg.id)
|
|
||||||
return {
|
|
||||||
info: msg,
|
|
||||||
parts,
|
|
||||||
shouldRetry,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let stream = doStream()
|
|
||||||
const cfg = await Config.get()
|
|
||||||
const maxRetries = cfg.experimental?.chatMaxRetries ?? MAX_RETRIES
|
|
||||||
let result = await process(stream, {
|
|
||||||
count: 0,
|
|
||||||
max: maxRetries,
|
|
||||||
})
|
|
||||||
if (result.shouldRetry) {
|
|
||||||
const start = Date.now()
|
|
||||||
for (let retry = 1; retry < maxRetries; retry++) {
|
|
||||||
const lastRetryPart = result.parts.findLast((p): p is MessageV2.RetryPart => p.type === "retry")
|
|
||||||
|
|
||||||
if (lastRetryPart) {
|
|
||||||
const delayMs = SessionRetry.getBoundedDelay({
|
|
||||||
error: lastRetryPart.error,
|
|
||||||
attempt: retry,
|
|
||||||
startTime: start,
|
|
||||||
})
|
|
||||||
if (!delayMs) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("retrying with backoff", {
|
|
||||||
attempt: retry,
|
|
||||||
delayMs,
|
|
||||||
elapsed: Date.now() - start,
|
|
||||||
})
|
|
||||||
|
|
||||||
const stop = await SessionRetry.sleep(delayMs, signal)
|
|
||||||
.then(() => false)
|
|
||||||
.catch((error) => {
|
|
||||||
if (error instanceof DOMException && error.name === "AbortError") {
|
|
||||||
const err = new MessageV2.AbortedError(
|
|
||||||
{ message: error.message },
|
|
||||||
{
|
|
||||||
cause: error,
|
|
||||||
},
|
|
||||||
).toObject()
|
|
||||||
result.info.error = err
|
|
||||||
Bus.publish(Session.Event.Error, {
|
|
||||||
sessionID: result.info.sessionID,
|
|
||||||
error: result.info.error,
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
|
|
||||||
if (stop) break
|
|
||||||
}
|
|
||||||
|
|
||||||
stream = doStream()
|
|
||||||
result = await process(stream, {
|
|
||||||
count: retry,
|
|
||||||
max: maxRetries,
|
|
||||||
})
|
|
||||||
if (!result.shouldRetry) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.time.completed = Date.now()
|
|
||||||
|
|
||||||
if (
|
|
||||||
!msg.error ||
|
|
||||||
(MessageV2.AbortedError.isInstance(msg.error) &&
|
|
||||||
result.parts.some((part): part is MessageV2.TextPart => part.type === "text" && part.text.length > 0))
|
|
||||||
) {
|
|
||||||
msg.summary = true
|
|
||||||
Bus.publish(Event.Compacted, {
|
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
time: {
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
agent: "build",
|
||||||
|
model: input.model,
|
||||||
|
})
|
||||||
|
await Session.updatePart({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
messageID: continueMsg.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
type: "text",
|
||||||
|
synthetic: true,
|
||||||
|
text: "Continue if you have next steps",
|
||||||
|
time: {
|
||||||
|
start: Date.now(),
|
||||||
|
end: Date.now(),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await Session.updateMessage(msg)
|
return "continue"
|
||||||
|
|
||||||
return {
|
|
||||||
info: msg,
|
|
||||||
parts: result.parts,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const create = fn(
|
||||||
|
z.object({
|
||||||
|
sessionID: Identifier.schema("session"),
|
||||||
|
model: z.object({
|
||||||
|
providerID: z.string(),
|
||||||
|
modelID: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (input) => {
|
||||||
|
const msg = await Session.updateMessage({
|
||||||
|
id: Identifier.ascending("message"),
|
||||||
|
role: "user",
|
||||||
|
model: input.model,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: "build",
|
||||||
|
time: {
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Session.updatePart({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
messageID: msg.id,
|
||||||
|
sessionID: msg.sessionID,
|
||||||
|
type: "compaction",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Decimal } from "decimal.js"
|
import { Decimal } from "decimal.js"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { type LanguageModelUsage, type ProviderMetadata } from "ai"
|
import { type LanguageModelUsage, type ProviderMetadata } from "ai"
|
||||||
|
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import z from "zod"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { NamedError } from "../util/error"
|
|
||||||
|
|
||||||
export namespace SessionLock {
|
|
||||||
const log = Log.create({ service: "session.lock" })
|
|
||||||
|
|
||||||
export const LockedError = NamedError.create(
|
|
||||||
"SessionLockedError",
|
|
||||||
z.object({
|
|
||||||
sessionID: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
type LockState = {
|
|
||||||
controller: AbortController
|
|
||||||
created: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = Instance.state(
|
|
||||||
() => {
|
|
||||||
const locks = new Map<string, LockState>()
|
|
||||||
return {
|
|
||||||
locks,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (current) => {
|
|
||||||
for (const [sessionID, lock] of current.locks) {
|
|
||||||
log.info("force abort", { sessionID })
|
|
||||||
lock.controller.abort()
|
|
||||||
}
|
|
||||||
current.locks.clear()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function get(sessionID: string) {
|
|
||||||
return state().locks.get(sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
function unset(input: { sessionID: string; controller: AbortController }) {
|
|
||||||
const lock = get(input.sessionID)
|
|
||||||
if (!lock) return false
|
|
||||||
if (lock.controller !== input.controller) return false
|
|
||||||
state().locks.delete(input.sessionID)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function acquire(input: { sessionID: string }) {
|
|
||||||
const lock = get(input.sessionID)
|
|
||||||
if (lock) {
|
|
||||||
throw new LockedError({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
message: `Session ${input.sessionID} is locked`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const controller = new AbortController()
|
|
||||||
state().locks.set(input.sessionID, {
|
|
||||||
controller,
|
|
||||||
created: Date.now(),
|
|
||||||
})
|
|
||||||
log.info("locked", { sessionID: input.sessionID })
|
|
||||||
return {
|
|
||||||
signal: controller.signal,
|
|
||||||
abort() {
|
|
||||||
controller.abort()
|
|
||||||
unset({ sessionID: input.sessionID, controller })
|
|
||||||
},
|
|
||||||
async [Symbol.dispose]() {
|
|
||||||
const removed = unset({ sessionID: input.sessionID, controller })
|
|
||||||
if (removed) {
|
|
||||||
log.info("unlocked", { sessionID: input.sessionID })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function abort(sessionID: string) {
|
|
||||||
const lock = get(sessionID)
|
|
||||||
if (!lock) return false
|
|
||||||
log.info("abort", { sessionID })
|
|
||||||
lock.controller.abort()
|
|
||||||
state().locks.delete(sessionID)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLocked(sessionID: string) {
|
|
||||||
return get(sessionID) !== undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertUnlocked(sessionID: string) {
|
|
||||||
const lock = get(sessionID)
|
|
||||||
if (!lock) return
|
|
||||||
throw new LockedError({ sessionID, message: `Session ${sessionID} is locked` })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -142,6 +142,21 @@ export namespace MessageV2 {
|
|||||||
})
|
})
|
||||||
export type AgentPart = z.infer<typeof AgentPart>
|
export type AgentPart = z.infer<typeof AgentPart>
|
||||||
|
|
||||||
|
export const CompactionPart = PartBase.extend({
|
||||||
|
type: z.literal("compaction"),
|
||||||
|
}).meta({
|
||||||
|
ref: "CompactionPart",
|
||||||
|
})
|
||||||
|
export type CompactionPart = z.infer<typeof CompactionPart>
|
||||||
|
|
||||||
|
export const SubtaskPart = PartBase.extend({
|
||||||
|
type: z.literal("subtask"),
|
||||||
|
prompt: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
agent: z.string(),
|
||||||
|
})
|
||||||
|
export type SubtaskPart = z.infer<typeof SubtaskPart>
|
||||||
|
|
||||||
export const RetryPart = PartBase.extend({
|
export const RetryPart = PartBase.extend({
|
||||||
type: z.literal("retry"),
|
type: z.literal("retry"),
|
||||||
attempt: z.number(),
|
attempt: z.number(),
|
||||||
@@ -277,6 +292,13 @@ export namespace MessageV2 {
|
|||||||
diffs: Snapshot.FileDiff.array(),
|
diffs: Snapshot.FileDiff.array(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
agent: z.string(),
|
||||||
|
model: z.object({
|
||||||
|
providerID: z.string(),
|
||||||
|
modelID: z.string(),
|
||||||
|
}),
|
||||||
|
system: z.string().optional(),
|
||||||
|
tools: z.record(z.string(), z.boolean()).optional(),
|
||||||
}).meta({
|
}).meta({
|
||||||
ref: "UserMessage",
|
ref: "UserMessage",
|
||||||
})
|
})
|
||||||
@@ -285,6 +307,7 @@ export namespace MessageV2 {
|
|||||||
export const Part = z
|
export const Part = z
|
||||||
.discriminatedUnion("type", [
|
.discriminatedUnion("type", [
|
||||||
TextPart,
|
TextPart,
|
||||||
|
SubtaskPart,
|
||||||
ReasoningPart,
|
ReasoningPart,
|
||||||
FilePart,
|
FilePart,
|
||||||
ToolPart,
|
ToolPart,
|
||||||
@@ -294,6 +317,7 @@ export namespace MessageV2 {
|
|||||||
PatchPart,
|
PatchPart,
|
||||||
AgentPart,
|
AgentPart,
|
||||||
RetryPart,
|
RetryPart,
|
||||||
|
CompactionPart,
|
||||||
])
|
])
|
||||||
.meta({
|
.meta({
|
||||||
ref: "Part",
|
ref: "Part",
|
||||||
@@ -334,6 +358,7 @@ export namespace MessageV2 {
|
|||||||
write: z.number(),
|
write: z.number(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
finish: z.string().optional(),
|
||||||
}).meta({
|
}).meta({
|
||||||
ref: "AssistantMessage",
|
ref: "AssistantMessage",
|
||||||
})
|
})
|
||||||
@@ -482,6 +507,11 @@ export namespace MessageV2 {
|
|||||||
time: {
|
time: {
|
||||||
created: v1.metadata.time.created,
|
created: v1.metadata.time.created,
|
||||||
},
|
},
|
||||||
|
agent: "build",
|
||||||
|
model: {
|
||||||
|
providerID: "opencode",
|
||||||
|
modelID: "opencode",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const parts = v1.parts.flatMap((part): Part[] => {
|
const parts = v1.parts.flatMap((part): Part[] => {
|
||||||
const base = {
|
const base = {
|
||||||
@@ -529,107 +559,107 @@ export namespace MessageV2 {
|
|||||||
if (msg.parts.length === 0) continue
|
if (msg.parts.length === 0) continue
|
||||||
|
|
||||||
if (msg.info.role === "user") {
|
if (msg.info.role === "user") {
|
||||||
result.push({
|
const userMessage: UIMessage = {
|
||||||
id: msg.info.id,
|
id: msg.info.id,
|
||||||
role: "user",
|
role: "user",
|
||||||
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
|
parts: [],
|
||||||
if (part.type === "text")
|
}
|
||||||
return [
|
result.push(userMessage)
|
||||||
{
|
for (const part of msg.parts) {
|
||||||
type: "text",
|
if (part.type === "text")
|
||||||
text: part.text,
|
userMessage.parts.push({
|
||||||
},
|
type: "text",
|
||||||
]
|
text: part.text,
|
||||||
// text/plain and directory files are converted into text parts, ignore them
|
})
|
||||||
if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory")
|
// text/plain and directory files are converted into text parts, ignore them
|
||||||
return [
|
if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory")
|
||||||
{
|
userMessage.parts.push({
|
||||||
type: "file",
|
type: "file",
|
||||||
url: part.url,
|
url: part.url,
|
||||||
mediaType: part.mime,
|
mediaType: part.mime,
|
||||||
filename: part.filename,
|
filename: part.filename,
|
||||||
},
|
})
|
||||||
]
|
|
||||||
return []
|
if (part.type === "compaction") {
|
||||||
}),
|
userMessage.parts.push({
|
||||||
})
|
type: "text",
|
||||||
|
text: "What did we do so far?",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (part.type === "subtask") {
|
||||||
|
userMessage.parts.push({
|
||||||
|
type: "text",
|
||||||
|
text: "The following tool was executed by the user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.info.role === "assistant") {
|
if (msg.info.role === "assistant") {
|
||||||
result.push({
|
const assistantMessage: UIMessage = {
|
||||||
id: msg.info.id,
|
id: msg.info.id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: msg.parts.flatMap((part): UIMessage["parts"] => {
|
parts: [],
|
||||||
if (part.type === "text")
|
}
|
||||||
return [
|
result.push(assistantMessage)
|
||||||
{
|
for (const part of msg.parts) {
|
||||||
type: "text",
|
if (part.type === "text")
|
||||||
text: part.text,
|
assistantMessage.parts.push({
|
||||||
providerMetadata: part.metadata,
|
type: "text",
|
||||||
},
|
text: part.text,
|
||||||
]
|
providerMetadata: part.metadata,
|
||||||
if (part.type === "step-start")
|
})
|
||||||
return [
|
if (part.type === "step-start")
|
||||||
{
|
assistantMessage.parts.push({
|
||||||
type: "step-start",
|
type: "step-start",
|
||||||
},
|
})
|
||||||
]
|
if (part.type === "tool") {
|
||||||
if (part.type === "tool") {
|
if (part.state.status === "completed") {
|
||||||
if (part.state.status === "completed") {
|
if (part.state.attachments?.length) {
|
||||||
if (part.state.attachments?.length) {
|
result.push({
|
||||||
result.push({
|
id: Identifier.ascending("message"),
|
||||||
id: Identifier.ascending("message"),
|
role: "user",
|
||||||
role: "user",
|
parts: [
|
||||||
parts: [
|
{
|
||||||
{
|
type: "text",
|
||||||
type: "text",
|
text: `Tool ${part.tool} returned an attachment:`,
|
||||||
text: `Tool ${part.tool} returned an attachment:`,
|
},
|
||||||
},
|
...part.state.attachments.map((attachment) => ({
|
||||||
...part.state.attachments.map((attachment) => ({
|
type: "file" as const,
|
||||||
type: "file" as const,
|
url: attachment.url,
|
||||||
url: attachment.url,
|
mediaType: attachment.mime,
|
||||||
mediaType: attachment.mime,
|
filename: attachment.filename,
|
||||||
filename: attachment.filename,
|
})),
|
||||||
})),
|
],
|
||||||
],
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
|
||||||
state: "output-available",
|
|
||||||
toolCallId: part.callID,
|
|
||||||
input: part.state.input,
|
|
||||||
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
|
|
||||||
callProviderMetadata: part.metadata,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
if (part.state.status === "error")
|
assistantMessage.parts.push({
|
||||||
return [
|
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||||
{
|
state: "output-available",
|
||||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
toolCallId: part.callID,
|
||||||
state: "output-error",
|
input: part.state.input,
|
||||||
toolCallId: part.callID,
|
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
|
||||||
input: part.state.input,
|
callProviderMetadata: part.metadata,
|
||||||
errorText: part.state.error,
|
})
|
||||||
callProviderMetadata: part.metadata,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
if (part.type === "reasoning") {
|
if (part.state.status === "error")
|
||||||
return [
|
assistantMessage.parts.push({
|
||||||
{
|
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||||
type: "reasoning",
|
state: "output-error",
|
||||||
text: part.text,
|
toolCallId: part.callID,
|
||||||
providerMetadata: part.metadata,
|
input: part.state.input,
|
||||||
},
|
errorText: part.state.error,
|
||||||
]
|
callProviderMetadata: part.metadata,
|
||||||
}
|
})
|
||||||
|
}
|
||||||
return []
|
if (part.type === "reasoning") {
|
||||||
}),
|
assistantMessage.parts.push({
|
||||||
})
|
type: "reasoning",
|
||||||
|
text: part.text,
|
||||||
|
providerMetadata: part.metadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,9 +701,16 @@ export namespace MessageV2 {
|
|||||||
|
|
||||||
export async function filterCompacted(stream: AsyncIterable<MessageV2.WithParts>) {
|
export async function filterCompacted(stream: AsyncIterable<MessageV2.WithParts>) {
|
||||||
const result = [] as MessageV2.WithParts[]
|
const result = [] as MessageV2.WithParts[]
|
||||||
|
const completed = new Set<string>()
|
||||||
for await (const msg of stream) {
|
for await (const msg of stream) {
|
||||||
result.push(msg)
|
result.push(msg)
|
||||||
if (msg.info.role === "assistant" && msg.info.summary === true) break
|
if (
|
||||||
|
msg.info.role === "user" &&
|
||||||
|
completed.has(msg.info.id) &&
|
||||||
|
msg.parts.some((part) => part.type === "compaction")
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish) completed.add(msg.info.parentID)
|
||||||
}
|
}
|
||||||
result.reverse()
|
result.reverse()
|
||||||
return result
|
return result
|
||||||
|
|||||||
372
packages/opencode/src/session/processor.ts
Normal file
372
packages/opencode/src/session/processor.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import type { ModelsDev } from "@/provider/models"
|
||||||
|
import { MessageV2 } from "./message-v2"
|
||||||
|
import { type StreamTextResult, type Tool as AITool, APICallError } from "ai"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { Identifier } from "@/id/id"
|
||||||
|
import { Session } from "."
|
||||||
|
import { Agent } from "@/agent/agent"
|
||||||
|
import { Permission } from "@/permission"
|
||||||
|
import { Snapshot } from "@/snapshot"
|
||||||
|
import { SessionSummary } from "./summary"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { SessionRetry } from "./retry"
|
||||||
|
import { SessionStatus } from "./status"
|
||||||
|
|
||||||
|
export namespace SessionProcessor {
|
||||||
|
const DOOM_LOOP_THRESHOLD = 3
|
||||||
|
const log = Log.create({ service: "session.processor" })
|
||||||
|
|
||||||
|
export type Info = Awaited<ReturnType<typeof create>>
|
||||||
|
export type Result = Awaited<ReturnType<Info["process"]>>
|
||||||
|
|
||||||
|
export function create(input: {
|
||||||
|
assistantMessage: MessageV2.Assistant
|
||||||
|
sessionID: string
|
||||||
|
providerID: string
|
||||||
|
model: ModelsDev.Model
|
||||||
|
abort: AbortSignal
|
||||||
|
}) {
|
||||||
|
const toolcalls: Record<string, MessageV2.ToolPart> = {}
|
||||||
|
let snapshot: string | undefined
|
||||||
|
let blocked = false
|
||||||
|
let attempt = 0
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
get message() {
|
||||||
|
return input.assistantMessage
|
||||||
|
},
|
||||||
|
partFromToolCall(toolCallID: string) {
|
||||||
|
return toolcalls[toolCallID]
|
||||||
|
},
|
||||||
|
async process(fn: () => StreamTextResult<Record<string, AITool>, never>) {
|
||||||
|
log.info("process")
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
let currentText: MessageV2.TextPart | undefined
|
||||||
|
let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
|
||||||
|
const stream = fn()
|
||||||
|
|
||||||
|
for await (const value of stream.fullStream) {
|
||||||
|
input.abort.throwIfAborted()
|
||||||
|
switch (value.type) {
|
||||||
|
case "start":
|
||||||
|
SessionStatus.set(input.sessionID, { type: "busy" })
|
||||||
|
break
|
||||||
|
|
||||||
|
case "reasoning-start":
|
||||||
|
if (value.id in reasoningMap) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reasoningMap[value.id] = {
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
messageID: input.assistantMessage.id,
|
||||||
|
sessionID: input.assistantMessage.sessionID,
|
||||||
|
type: "reasoning",
|
||||||
|
text: "",
|
||||||
|
time: {
|
||||||
|
start: Date.now(),
|
||||||
|
},
|
||||||
|
metadata: value.providerMetadata,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case "reasoning-delta":
|
||||||
|
if (value.id in reasoningMap) {
|
||||||
|
const part = reasoningMap[value.id]
|
||||||
|
part.text += value.text
|
||||||
|
if (value.providerMetadata) part.metadata = value.providerMetadata
|
||||||
|
if (part.text) await Session.updatePart({ part, delta: value.text })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case "reasoning-end":
|
||||||
|
if (value.id in reasoningMap) {
|
||||||
|
const part = reasoningMap[value.id]
|
||||||
|
part.text = part.text.trimEnd()
|
||||||
|
|
||||||
|
part.time = {
|
||||||
|
...part.time,
|
||||||
|
end: Date.now(),
|
||||||
|
}
|
||||||
|
if (value.providerMetadata) part.metadata = value.providerMetadata
|
||||||
|
await Session.updatePart(part)
|
||||||
|
delete reasoningMap[value.id]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case "tool-input-start":
|
||||||
|
const part = await Session.updatePart({
|
||||||
|
id: toolcalls[value.id]?.id ?? Identifier.ascending("part"),
|
||||||
|
messageID: input.assistantMessage.id,
|
||||||
|
sessionID: input.assistantMessage.sessionID,
|
||||||
|
type: "tool",
|
||||||
|
tool: value.toolName,
|
||||||
|
callID: value.id,
|
||||||
|
state: {
|
||||||
|
status: "pending",
|
||||||
|
input: {},
|
||||||
|
raw: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toolcalls[value.id] = part as MessageV2.ToolPart
|
||||||
|
break
|
||||||
|
|
||||||
|
case "tool-input-delta":
|
||||||
|
break
|
||||||
|
|
||||||
|
case "tool-input-end":
|
||||||
|
break
|
||||||
|
|
||||||
|
case "tool-call": {
|
||||||
|
const match = toolcalls[value.toolCallId]
|
||||||
|
if (match) {
|
||||||
|
const part = await Session.updatePart({
|
||||||
|
...match,
|
||||||
|
tool: value.toolName,
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: value.input,
|
||||||
|
time: {
|
||||||
|
start: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: value.providerMetadata,
|
||||||
|
})
|
||||||
|
toolcalls[value.toolCallId] = part as MessageV2.ToolPart
|
||||||
|
|
||||||
|
const parts = await MessageV2.parts(input.assistantMessage.id)
|
||||||
|
const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD)
|
||||||
|
if (
|
||||||
|
lastThree.length === DOOM_LOOP_THRESHOLD &&
|
||||||
|
lastThree.every(
|
||||||
|
(p) =>
|
||||||
|
p.type === "tool" &&
|
||||||
|
p.tool === value.toolName &&
|
||||||
|
p.state.status !== "pending" &&
|
||||||
|
JSON.stringify(p.state.input) === JSON.stringify(value.input),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const permission = await Agent.get(input.assistantMessage.mode).then((x) => x.permission)
|
||||||
|
if (permission.doom_loop === "ask") {
|
||||||
|
await Permission.ask({
|
||||||
|
type: "doom_loop",
|
||||||
|
pattern: value.toolName,
|
||||||
|
sessionID: input.assistantMessage.sessionID,
|
||||||
|
messageID: input.assistantMessage.id,
|
||||||
|
callID: value.toolCallId,
|
||||||
|
title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`,
|
||||||
|
metadata: {
|
||||||
|
tool: value.toolName,
|
||||||
|
input: value.input,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "tool-result": {
|
||||||
|
const match = toolcalls[value.toolCallId]
|
||||||
|
if (match && match.state.status === "running") {
|
||||||
|
await Session.updatePart({
|
||||||
|
...match,
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: value.input,
|
||||||
|
output: value.output.output,
|
||||||
|
metadata: value.output.metadata,
|
||||||
|
title: value.output.title,
|
||||||
|
time: {
|
||||||
|
start: match.state.time.start,
|
||||||
|
end: Date.now(),
|
||||||
|
},
|
||||||
|
attachments: value.output.attachments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
delete toolcalls[value.toolCallId]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tool-error": {
|
||||||
|
const match = toolcalls[value.toolCallId]
|
||||||
|
if (match && match.state.status === "running") {
|
||||||
|
await Session.updatePart({
|
||||||
|
...match,
|
||||||
|
state: {
|
||||||
|
status: "error",
|
||||||
|
input: value.input,
|
||||||
|
error: (value.error as any).toString(),
|
||||||
|
metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined,
|
||||||
|
time: {
|
||||||
|
start: match.state.time.start,
|
||||||
|
end: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (value.error instanceof Permission.RejectedError) {
|
||||||
|
blocked = true
|
||||||
|
}
|
||||||
|
delete toolcalls[value.toolCallId]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
throw value.error
|
||||||
|
|
||||||
|
case "start-step":
|
||||||
|
snapshot = await Snapshot.track()
|
||||||
|
await Session.updatePart({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
messageID: input.assistantMessage.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
snapshot,
|
||||||
|
type: "step-start",
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "finish-step":
|
||||||
|
const usage = Session.getUsage({
|
||||||
|
model: input.model,
|
||||||
|
usage: value.usage,
|
||||||
|
metadata: value.providerMetadata,
|
||||||
|
})
|
||||||
|
input.assistantMessage.finish = value.finishReason
|
||||||
|
input.assistantMessage.cost += usage.cost
|
||||||
|
input.assistantMessage.tokens = usage.tokens
|
||||||
|
await Session.updatePart({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
reason: value.finishReason,
|
||||||
|
snapshot: await Snapshot.track(),
|
||||||
|
messageID: input.assistantMessage.id,
|
||||||
|
sessionID: input.assistantMessage.sessionID,
|
||||||
|
type: "step-finish",
|
||||||
|
tokens: usage.tokens,
|
||||||
|
cost: usage.cost,
|
||||||
|
})
|
||||||
|
await Session.updateMessage(input.assistantMessage)
|
||||||
|
if (snapshot) {
|
||||||
|
const patch = await Snapshot.patch(snapshot)
|
||||||
|
if (patch.files.length) {
|
||||||
|
await Session.updatePart({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
messageID: input.assistantMessage.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
type: "patch",
|
||||||
|
hash: patch.hash,
|
||||||
|
files: patch.files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
snapshot = undefined
|
||||||
|
}
|
||||||
|
SessionSummary.summarize({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: input.assistantMessage.parentID,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "text-start":
|
||||||
|
currentText = {
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
messageID: input.assistantMessage.id,
|
||||||
|
sessionID: input.assistantMessage.sessionID,
|
||||||
|
type: "text",
|
||||||
|
text: "",
|
||||||
|
time: {
|
||||||
|
start: Date.now(),
|
||||||
|
},
|
||||||
|
metadata: value.providerMetadata,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case "text-delta":
|
||||||
|
if (currentText) {
|
||||||
|
currentText.text += value.text
|
||||||
|
if (value.providerMetadata) currentText.metadata = value.providerMetadata
|
||||||
|
if (currentText.text)
|
||||||
|
await Session.updatePart({
|
||||||
|
part: currentText,
|
||||||
|
delta: value.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case "text-end":
|
||||||
|
if (currentText) {
|
||||||
|
currentText.text = currentText.text.trimEnd()
|
||||||
|
currentText.time = {
|
||||||
|
start: Date.now(),
|
||||||
|
end: Date.now(),
|
||||||
|
}
|
||||||
|
if (value.providerMetadata) currentText.metadata = value.providerMetadata
|
||||||
|
await Session.updatePart(currentText)
|
||||||
|
}
|
||||||
|
currentText = undefined
|
||||||
|
break
|
||||||
|
|
||||||
|
case "finish":
|
||||||
|
input.assistantMessage.time.completed = Date.now()
|
||||||
|
await Session.updateMessage(input.assistantMessage)
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.info("unhandled", {
|
||||||
|
...value,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.error("process", {
|
||||||
|
error: e,
|
||||||
|
})
|
||||||
|
const error = MessageV2.fromError(e, { providerID: input.providerID })
|
||||||
|
if (error?.name === "APIError" && error.data.isRetryable) {
|
||||||
|
attempt++
|
||||||
|
const delay = SessionRetry.getRetryDelayInMs(error, attempt)
|
||||||
|
if (delay) {
|
||||||
|
SessionStatus.set(input.sessionID, {
|
||||||
|
type: "retry",
|
||||||
|
attempt,
|
||||||
|
message: error.data.message,
|
||||||
|
})
|
||||||
|
await SessionRetry.sleep(delay, input.abort).catch(() => {})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.assistantMessage.error = error
|
||||||
|
Bus.publish(Session.Event.Error, {
|
||||||
|
sessionID: input.assistantMessage.sessionID,
|
||||||
|
error: input.assistantMessage.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const p = await MessageV2.parts(input.assistantMessage.id)
|
||||||
|
for (const part of p) {
|
||||||
|
if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") {
|
||||||
|
await Session.updatePart({
|
||||||
|
...part,
|
||||||
|
state: {
|
||||||
|
...part.state,
|
||||||
|
status: "error",
|
||||||
|
error: "Tool execution aborted",
|
||||||
|
time: {
|
||||||
|
start: Date.now(),
|
||||||
|
end: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.assistantMessage.time.completed = Date.now()
|
||||||
|
await Session.updateMessage(input.assistantMessage)
|
||||||
|
if (blocked) return "stop"
|
||||||
|
if (input.assistantMessage.error) return "stop"
|
||||||
|
return "continue"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import { Log } from "../util/log"
|
|||||||
import { splitWhen } from "remeda"
|
import { splitWhen } from "remeda"
|
||||||
import { Storage } from "../storage/storage"
|
import { Storage } from "../storage/storage"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { SessionLock } from "./lock"
|
import { SessionPrompt } from "./prompt"
|
||||||
|
|
||||||
export namespace SessionRevert {
|
export namespace SessionRevert {
|
||||||
const log = Log.create({ service: "session.revert" })
|
const log = Log.create({ service: "session.revert" })
|
||||||
@@ -20,11 +20,7 @@ export namespace SessionRevert {
|
|||||||
export type RevertInput = z.infer<typeof RevertInput>
|
export type RevertInput = z.infer<typeof RevertInput>
|
||||||
|
|
||||||
export async function revert(input: RevertInput) {
|
export async function revert(input: RevertInput) {
|
||||||
SessionLock.assertUnlocked(input.sessionID)
|
SessionPrompt.assertNotBusy(input.sessionID)
|
||||||
using _ = SessionLock.acquire({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
})
|
|
||||||
|
|
||||||
const all = await Session.messages({ sessionID: input.sessionID })
|
const all = await Session.messages({ sessionID: input.sessionID })
|
||||||
let lastUser: MessageV2.User | undefined
|
let lastUser: MessageV2.User | undefined
|
||||||
const session = await Session.get(input.sessionID)
|
const session = await Session.get(input.sessionID)
|
||||||
@@ -70,10 +66,7 @@ export namespace SessionRevert {
|
|||||||
|
|
||||||
export async function unrevert(input: { sessionID: string }) {
|
export async function unrevert(input: { sessionID: string }) {
|
||||||
log.info("unreverting", input)
|
log.info("unreverting", input)
|
||||||
SessionLock.assertUnlocked(input.sessionID)
|
SessionPrompt.assertNotBusy(input.sessionID)
|
||||||
using _ = SessionLock.acquire({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
})
|
|
||||||
const session = await Session.get(input.sessionID)
|
const session = await Session.get(input.sessionID)
|
||||||
if (!session.revert) return session
|
if (!session.revert) return session
|
||||||
if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot)
|
if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot)
|
||||||
|
|||||||
63
packages/opencode/src/session/status.ts
Normal file
63
packages/opencode/src/session/status.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import z from "zod"
|
||||||
|
|
||||||
|
export namespace SessionStatus {
|
||||||
|
export const Info = z
|
||||||
|
.union([
|
||||||
|
z.object({
|
||||||
|
type: z.literal("idle"),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("retry"),
|
||||||
|
attempt: z.number(),
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("busy"),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.meta({
|
||||||
|
ref: "SessionStatus",
|
||||||
|
})
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Status: Bus.event(
|
||||||
|
"session.status",
|
||||||
|
z.object({
|
||||||
|
sessionID: z.string(),
|
||||||
|
status: Info,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = Instance.state(() => {
|
||||||
|
const data: Record<string, Info> = {}
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
|
||||||
|
export function get(sessionID: string) {
|
||||||
|
return (
|
||||||
|
state()[sessionID] ?? {
|
||||||
|
type: "idle",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function list() {
|
||||||
|
return Object.values(state())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set(sessionID: string, status: Info) {
|
||||||
|
Bus.publish(Event.Status, {
|
||||||
|
sessionID,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
if (status.type === "idle") {
|
||||||
|
delete state()[sessionID]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state()[sessionID] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ export namespace SystemPrompt {
|
|||||||
` Platform: ${process.platform}`,
|
` Platform: ${process.platform}`,
|
||||||
` Today's date: ${new Date().toDateString()}`,
|
` Today's date: ${new Date().toDateString()}`,
|
||||||
`</env>`,
|
`</env>`,
|
||||||
`<project>`,
|
`<files>`,
|
||||||
` ${
|
` ${
|
||||||
project.vcs === "git"
|
project.vcs === "git"
|
||||||
? await Ripgrep.tree({
|
? await Ripgrep.tree({
|
||||||
@@ -52,7 +52,7 @@ export namespace SystemPrompt {
|
|||||||
})
|
})
|
||||||
: ""
|
: ""
|
||||||
}`,
|
}`,
|
||||||
`</project>`,
|
`</files>`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { Bus } from "../bus"
|
|||||||
import { MessageV2 } from "../session/message-v2"
|
import { MessageV2 } from "../session/message-v2"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { SessionLock } from "../session/lock"
|
|
||||||
import { SessionPrompt } from "../session/prompt"
|
import { SessionPrompt } from "../session/prompt"
|
||||||
|
import { defer } from "@/util/defer"
|
||||||
|
|
||||||
export const TaskTool = Tool.define("task", async () => {
|
export const TaskTool = Tool.define("task", async () => {
|
||||||
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
||||||
@@ -62,9 +62,11 @@ export const TaskTool = Tool.define("task", async () => {
|
|||||||
providerID: msg.info.providerID,
|
providerID: msg.info.providerID,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.abort.addEventListener("abort", () => {
|
function cancel() {
|
||||||
SessionLock.abort(session.id)
|
SessionPrompt.cancel(session.id)
|
||||||
})
|
}
|
||||||
|
ctx.abort.addEventListener("abort", cancel)
|
||||||
|
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
|
||||||
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
|
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
|
||||||
const result = await SessionPrompt.prompt({
|
const result = await SessionPrompt.prompt({
|
||||||
messageID,
|
messageID,
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import type {
|
|||||||
SessionCreateData,
|
SessionCreateData,
|
||||||
SessionCreateResponses,
|
SessionCreateResponses,
|
||||||
SessionCreateErrors,
|
SessionCreateErrors,
|
||||||
|
SessionStatusData,
|
||||||
|
SessionStatusResponses,
|
||||||
|
SessionStatusErrors,
|
||||||
SessionDeleteData,
|
SessionDeleteData,
|
||||||
SessionDeleteResponses,
|
SessionDeleteResponses,
|
||||||
SessionDeleteErrors,
|
SessionDeleteErrors,
|
||||||
@@ -306,6 +309,16 @@ class Session extends _HeyApiClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session status
|
||||||
|
*/
|
||||||
|
public status<ThrowOnError extends boolean = false>(options?: Options<SessionStatusData, ThrowOnError>) {
|
||||||
|
return (options?.client ?? this._client).get<SessionStatusResponses, SessionStatusErrors, ThrowOnError>({
|
||||||
|
url: "/session/status",
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a session and all its data
|
* Delete a session and all its data
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ export type UserMessage = {
|
|||||||
body?: string
|
body?: string
|
||||||
diffs: Array<FileDiff>
|
diffs: Array<FileDiff>
|
||||||
}
|
}
|
||||||
|
agent: string
|
||||||
|
model: {
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
}
|
||||||
|
system?: string
|
||||||
|
tools?: {
|
||||||
|
[key: string]: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderAuthError = {
|
export type ProviderAuthError = {
|
||||||
@@ -114,6 +123,7 @@ export type AssistantMessage = {
|
|||||||
write: number
|
write: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finish?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Message = UserMessage | AssistantMessage
|
export type Message = UserMessage | AssistantMessage
|
||||||
@@ -348,6 +358,13 @@ export type RetryPart = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CompactionPart = {
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
type: "compaction"
|
||||||
|
}
|
||||||
|
|
||||||
export type Part =
|
export type Part =
|
||||||
| TextPart
|
| TextPart
|
||||||
| ReasoningPart
|
| ReasoningPart
|
||||||
@@ -359,6 +376,7 @@ export type Part =
|
|||||||
| PatchPart
|
| PatchPart
|
||||||
| AgentPart
|
| AgentPart
|
||||||
| RetryPart
|
| RetryPart
|
||||||
|
| CompactionPart
|
||||||
|
|
||||||
export type EventMessagePartUpdated = {
|
export type EventMessagePartUpdated = {
|
||||||
type: "message.part.updated"
|
type: "message.part.updated"
|
||||||
@@ -377,13 +395,6 @@ export type EventMessagePartRemoved = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventSessionCompacted = {
|
|
||||||
type: "session.compacted"
|
|
||||||
properties: {
|
|
||||||
sessionID: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Permission = {
|
export type Permission = {
|
||||||
id: string
|
id: string
|
||||||
type: string
|
type: string
|
||||||
@@ -414,6 +425,13 @@ export type EventPermissionReplied = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventSessionCompacted = {
|
||||||
|
type: "session.compacted"
|
||||||
|
properties: {
|
||||||
|
sessionID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type EventFileEdited = {
|
export type EventFileEdited = {
|
||||||
type: "file.edited"
|
type: "file.edited"
|
||||||
properties: {
|
properties: {
|
||||||
@@ -458,6 +476,27 @@ export type EventCommandExecuted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionStatus =
|
||||||
|
| {
|
||||||
|
type: "idle"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "retry"
|
||||||
|
attempt: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "busy"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventSessionStatus = {
|
||||||
|
type: "session.status"
|
||||||
|
properties: {
|
||||||
|
sessionID: string
|
||||||
|
status: SessionStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type EventSessionIdle = {
|
export type EventSessionIdle = {
|
||||||
type: "session.idle"
|
type: "session.idle"
|
||||||
properties: {
|
properties: {
|
||||||
@@ -598,12 +637,13 @@ export type Event =
|
|||||||
| EventMessageRemoved
|
| EventMessageRemoved
|
||||||
| EventMessagePartUpdated
|
| EventMessagePartUpdated
|
||||||
| EventMessagePartRemoved
|
| EventMessagePartRemoved
|
||||||
| EventSessionCompacted
|
|
||||||
| EventPermissionUpdated
|
| EventPermissionUpdated
|
||||||
| EventPermissionReplied
|
| EventPermissionReplied
|
||||||
|
| EventSessionCompacted
|
||||||
| EventFileEdited
|
| EventFileEdited
|
||||||
| EventTodoUpdated
|
| EventTodoUpdated
|
||||||
| EventCommandExecuted
|
| EventCommandExecuted
|
||||||
|
| EventSessionStatus
|
||||||
| EventSessionIdle
|
| EventSessionIdle
|
||||||
| EventSessionCreated
|
| EventSessionCreated
|
||||||
| EventSessionUpdated
|
| EventSessionUpdated
|
||||||
@@ -1613,6 +1653,35 @@ export type SessionCreateResponses = {
|
|||||||
|
|
||||||
export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses]
|
export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses]
|
||||||
|
|
||||||
|
export type SessionStatusData = {
|
||||||
|
body?: never
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
}
|
||||||
|
url: "/session/status"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionStatusErrors = {
|
||||||
|
/**
|
||||||
|
* Bad request
|
||||||
|
*/
|
||||||
|
400: BadRequestError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionStatusError = SessionStatusErrors[keyof SessionStatusErrors]
|
||||||
|
|
||||||
|
export type SessionStatusResponses = {
|
||||||
|
/**
|
||||||
|
* Get session status
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
[key: string]: SessionStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionStatusResponse = SessionStatusResponses[keyof SessionStatusResponses]
|
||||||
|
|
||||||
export type SessionDeleteData = {
|
export type SessionDeleteData = {
|
||||||
body?: never
|
body?: never
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
Reference in New Issue
Block a user