diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6889feb9..75ab2867 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -24,6 +24,7 @@ import { AuthAnthropic } from "../auth/anthropic" import { ModelsDev } from "./models" import { NamedError } from "../util/error" import { Auth } from "../auth" +import { TaskTool } from "../tool/task" export namespace Provider { const log = Log.create({ service: "provider" }) @@ -298,6 +299,7 @@ export namespace Provider { // MultiEditTool, WriteTool, TodoWriteTool, + TaskTool, TodoReadTool, ] const TOOL_MAPPING: Record = { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 601757e7..ca0ebf44 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -12,24 +12,21 @@ import { tool, type Tool as AITool, type LanguageModelUsage, + type UIMessage, } from "ai" import { z, ZodSchema } from "zod" import { Decimal } from "decimal.js" -import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" -import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" -import PROMPT_TITLE from "./prompt/title.txt" -import PROMPT_SUMMARIZE from "./prompt/summarize.txt" import PROMPT_INITIALIZE from "../session/prompt/initialize.txt" import { Share } from "../share/share" import { Message } from "./message" import { Bus } from "../bus" import { Provider } from "../provider/provider" -import { SessionContext } from "./context" -import { ListTool } from "../tool/ls" import { MCP } from "../mcp" import { NamedError } from "../util/error" +import type { Tool } from "../tool/tool" +import { SystemPrompt } from "./system" export namespace Session { const log = Log.create({ service: "session" }) @@ -37,6 +34,7 @@ export namespace Session { export const Info = z .object({ id: Identifier.schema("session"), + parentID: Identifier.schema("session").optional(), share: z .object({ secret: z.string(), @@ -79,10 +77,11 @@ export namespace Session { } }) - export async function create() { + export async function create(parentID?: string) { const result: Info = { id: Identifier.descending("session"), - title: "New Session - " + new Date().toISOString(), + parentID, + title: "Child Session - " + new Date().toISOString(), time: { created: Date.now(), updated: Date.now(), @@ -91,11 +90,12 @@ export namespace Session { log.info("created", result) state().sessions.set(result.id, result) await Storage.writeJSON("session/info/" + result.id, result) - share(result.id).then((share) => { - update(result.id, (draft) => { - draft.share = share + if (!result.parentID) + share(result.id).then((share) => { + update(result.id, (draft) => { + draft.share = share + }) }) - }) Bus.publish(Event.Updated, { info: result, }) @@ -186,12 +186,16 @@ export namespace Session { providerID: string modelID: string parts: Message.Part[] + system?: string[] + tools?: Tool.Info[] }) { const l = log.clone().tag("session", input.sessionID) l.info("chatting") const model = await Provider.getModel(input.providerID, input.modelID) let msgs = await messages(input.sessionID) const previous = msgs.at(-1) + + // auto summarize if too long if (previous?.metadata.assistant) { const tokens = previous.metadata.assistant.tokens.input + @@ -214,95 +218,25 @@ export namespace Session { const lastSummary = msgs.findLast( (msg) => msg.metadata.assistant?.summary === true, ) - if (lastSummary) - msgs = msgs.filter( - (msg) => msg.role === "system" || msg.id >= lastSummary.id, - ) + if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id) + const app = App.info() if (msgs.length === 0) { - const app = App.info() - if (input.providerID === "anthropic") { - const claude: Message.Info = { - id: Identifier.ascending("message"), - role: "system", - parts: [ - { - type: "text", - text: PROMPT_ANTHROPIC_SPOOF.trim(), - }, - ], - metadata: { - sessionID: input.sessionID, - time: { - created: Date.now(), - }, - tool: {}, - }, - } - await updateMessage(claude) - msgs.push(claude) - } - const system: Message.Info = { - id: Identifier.ascending("message"), - role: "system", - parts: [ - { - type: "text", - text: PROMPT_ANTHROPIC, - }, - { - type: "text", - text: [ - `Here is some useful information about the environment you are running in:`, - ``, - `Working directory: ${app.path.cwd}`, - `Is directory a git repo: ${app.git ? "yes" : "no"}`, - `Platform: ${process.platform}`, - `Today's date: ${new Date().toISOString()}`, - ``, - ``, - `${app.git ? await ListTool.execute({ path: app.path.cwd, ignore: [] }, { sessionID: input.sessionID, abort: abort.signal }).then((x) => x.output) : ""}`, - ``, - ].join("\n"), - }, - ], - metadata: { - sessionID: input.sessionID, - time: { - created: Date.now(), - }, - tool: {}, - }, - } - const context = await SessionContext.find() - if (context) { - system.parts.push({ - type: "text", - text: context, - }) - } - msgs.push(system) generateText({ maxOutputTokens: 20, messages: convertToModelMessages([ - { - role: "system", - parts: [ - { - type: "text", - text: PROMPT_ANTHROPIC_SPOOF.trim(), - }, - ], - }, - { - role: "system", - parts: [ - { - type: "text", - text: PROMPT_TITLE, - }, - ], - }, + ...SystemPrompt.title(input.providerID).map( + (x): UIMessage => ({ + id: Identifier.ascending("message"), + role: "system", + parts: [ + { + type: "text", + text: x, + }, + ], + }), + ), { role: "user", parts: input.parts, @@ -317,7 +251,6 @@ export namespace Session { }) }) .catch(() => {}) - await updateMessage(system) } const msg: Message.Info = { role: "user", @@ -334,12 +267,21 @@ export namespace Session { await updateMessage(msg) msgs.push(msg) + const system = input.system ?? SystemPrompt.provider(input.providerID) + system.push(...(await SystemPrompt.environment(input.sessionID))) + system.push(...(await SystemPrompt.custom())) + const next: Message.Info = { id: Identifier.ascending("message"), role: "assistant", parts: [], metadata: { assistant: { + system, + path: { + cwd: app.path.cwd, + root: app.path.root, + }, cost: 0, tokens: { input: 0, @@ -358,6 +300,7 @@ export namespace Session { } await updateMessage(next) const tools: Record = {} + for (const item of await Provider.tools(input.providerID)) { tools[item.id.replaceAll(".", "_")] = tool({ id: item.id as any, @@ -369,6 +312,7 @@ export namespace Session { const result = await item.execute(args, { sessionID: input.sessionID, abort: abort.signal, + messageID: next.id, }) next.metadata!.tool![opts.toolCallId] = { ...result.metadata, @@ -395,6 +339,7 @@ export namespace Session { }, }) } + for (const [key, item] of Object.entries(await MCP.tools())) { const execute = item.execute if (!execute) continue @@ -576,7 +521,21 @@ export namespace Session { toolCallStreaming: true, abortSignal: abort.signal, stopWhen: stepCountIs(1000), - messages: convertToModelMessages(msgs), + messages: convertToModelMessages([ + ...system.map( + (x): UIMessage => ({ + id: Identifier.ascending("message"), + role: "system", + parts: [ + { + type: "text", + text: x, + }, + ], + }), + ), + ...msgs, + ]), temperature: model.info.id === "codex-mini-latest" ? undefined : 0, tools: { ...(await MCP.tools()), @@ -618,10 +577,11 @@ export namespace Session { const lastSummary = msgs.findLast( (msg) => msg.metadata.assistant?.summary === true, )?.id - const filtered = msgs.filter( - (msg) => msg.role !== "system" && (!lastSummary || msg.id >= lastSummary), - ) + const filtered = msgs.filter((msg) => !lastSummary || msg.id >= lastSummary) const model = await Provider.getModel(input.providerID, input.modelID) + const app = App.info() + const system = SystemPrompt.summarize(input.providerID) + const next: Message.Info = { id: Identifier.ascending("message"), role: "assistant", @@ -630,6 +590,11 @@ export namespace Session { tool: {}, sessionID: input.sessionID, assistant: { + system, + path: { + cwd: app.path.cwd, + root: app.path.root, + }, summary: true, cost: 0, modelID: input.modelID, @@ -650,15 +615,18 @@ export namespace Session { abortSignal: abort.signal, model: model.language, messages: convertToModelMessages([ - { - role: "system", - parts: [ - { - type: "text", - text: PROMPT_SUMMARIZE, - }, - ], - }, + ...system.map( + (x): UIMessage => ({ + id: Identifier.ascending("message"), + role: "system", + parts: [ + { + type: "text", + text: x, + }, + ], + }), + ), ...filtered, { role: "user", diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index d9ac88d7..d73eee54 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -133,7 +133,7 @@ export namespace Message { export const Info = z .object({ id: z.string(), - role: z.enum(["system", "user", "assistant"]), + role: z.enum(["user", "assistant"]), parts: z.array(Part), metadata: z.object({ time: z.object({ @@ -161,8 +161,13 @@ export namespace Message { ), assistant: z .object({ + system: z.string().array(), modelID: z.string(), providerID: z.string(), + path: z.object({ + cwd: z.string(), + root: z.string(), + }), cost: z.number(), summary: z.boolean().optional(), tokens: z.object({ diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts new file mode 100644 index 00000000..5740cce0 --- /dev/null +++ b/packages/opencode/src/session/system.ts @@ -0,0 +1,75 @@ +import { App } from "../app/app" +import { ListTool } from "../tool/ls" +import { Filesystem } from "../util/filesystem" + +import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" +import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" +import PROMPT_SUMMARIZE from "./prompt/summarize.txt" +import PROMPT_TITLE from "./prompt/title.txt" + +export namespace SystemPrompt { + export function provider(providerID: string) { + const result = [] + switch (providerID) { + case "anthropic": + result.push(PROMPT_ANTHROPIC_SPOOF.trim()) + result.push(PROMPT_ANTHROPIC) + break + default: + result.push(PROMPT_ANTHROPIC) + break + } + return result + } + + export async function environment(sessionID: string) { + const app = App.info() + return [ + [ + `Here is some useful information about the environment you are running in:`, + ``, + ` Working directory: ${app.path.cwd}`, + ` Is directory a git repo: ${app.git ? "yes" : "no"}`, + ` Platform: ${process.platform}`, + ` Today's date: ${new Date().toDateString()}`, + ``, + ``, + ` ${app.git ? await ListTool.execute({ path: app.path.cwd, ignore: [] }, { sessionID: sessionID, messageID: "", abort: AbortSignal.any([]) }).then((x) => x.output) : ""}`, + ``, + ].join("\n"), + ] + } + + const CUSTOM_FILES = [ + "AGENTS.md", + "CLAUDE.md", + "CONTEXT.md", // deprecated + ] + export async function custom() { + const { cwd, root } = App.info().path + const found = [] + for (const item of CUSTOM_FILES) { + const matches = await Filesystem.findUp(item, cwd, root) + found.push(...matches.map((x) => Bun.file(x).text())) + } + return Promise.all(found) + } + + export function summarize(providerID: string) { + switch (providerID) { + case "anthropic": + return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_SUMMARIZE] + default: + return [PROMPT_SUMMARIZE] + } + } + + export function title(providerID: string) { + switch (providerID) { + case "anthropic": + return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_TITLE] + default: + return [PROMPT_TITLE] + } + } +} diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts new file mode 100644 index 00000000..ce1e1dc0 --- /dev/null +++ b/packages/opencode/src/tool/task.ts @@ -0,0 +1,39 @@ +import { Tool } from "./tool" +import DESCRIPTION from "./task.txt" +import { z } from "zod" +import { Session } from "../session" + +export const TaskTool = Tool.define({ + id: "opencode.task", + description: DESCRIPTION, + parameters: z.object({ + description: z + .string() + .describe("A short (3-5 words) description of the task"), + prompt: z.string().describe("The task for the agent to perform"), + }), + async execute(params, ctx) { + const session = await Session.create(ctx.sessionID) + const msg = await Session.getMessage(ctx.sessionID, ctx.messageID) + const metadata = msg.metadata.assistant! + + const result = await Session.chat({ + sessionID: session.id, + modelID: metadata.modelID, + providerID: metadata.providerID, + parts: [ + { + type: "text", + text: params.prompt, + }, + ], + }) + + return { + metadata: { + title: params.description, + }, + output: result.parts.findLast((x) => x.type === "text")!.text, + } + }, +}) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index b573f758..ccbcaffe 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -7,6 +7,7 @@ export namespace Tool { } export type Context = { sessionID: string + messageID: string abort: AbortSignal } export interface Info<