This commit is contained in:
Dax Raad
2025-06-12 17:04:34 -04:00
parent 5eae7aef0e
commit a454ba8895
6 changed files with 201 additions and 111 deletions

View File

@@ -24,6 +24,7 @@ import { AuthAnthropic } from "../auth/anthropic"
import { ModelsDev } from "./models" import { ModelsDev } from "./models"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import { Auth } from "../auth" import { Auth } from "../auth"
import { TaskTool } from "../tool/task"
export namespace Provider { export namespace Provider {
const log = Log.create({ service: "provider" }) const log = Log.create({ service: "provider" })
@@ -298,6 +299,7 @@ export namespace Provider {
// MultiEditTool, // MultiEditTool,
WriteTool, WriteTool,
TodoWriteTool, TodoWriteTool,
TaskTool,
TodoReadTool, TodoReadTool,
] ]
const TOOL_MAPPING: Record<string, Tool.Info[]> = { const TOOL_MAPPING: Record<string, Tool.Info[]> = {

View File

@@ -12,24 +12,21 @@ import {
tool, tool,
type Tool as AITool, type Tool as AITool,
type LanguageModelUsage, type LanguageModelUsage,
type UIMessage,
} from "ai" } from "ai"
import { z, ZodSchema } from "zod" import { z, ZodSchema } from "zod"
import { Decimal } from "decimal.js" 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 PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
import { Share } from "../share/share" import { Share } from "../share/share"
import { Message } from "./message" import { Message } from "./message"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Provider } from "../provider/provider" import { Provider } from "../provider/provider"
import { SessionContext } from "./context"
import { ListTool } from "../tool/ls"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { NamedError } from "../util/error" import { NamedError } from "../util/error"
import type { Tool } from "../tool/tool"
import { SystemPrompt } from "./system"
export namespace Session { export namespace Session {
const log = Log.create({ service: "session" }) const log = Log.create({ service: "session" })
@@ -37,6 +34,7 @@ export namespace Session {
export const Info = z export const Info = z
.object({ .object({
id: Identifier.schema("session"), id: Identifier.schema("session"),
parentID: Identifier.schema("session").optional(),
share: z share: z
.object({ .object({
secret: z.string(), secret: z.string(),
@@ -79,10 +77,11 @@ export namespace Session {
} }
}) })
export async function create() { export async function create(parentID?: string) {
const result: Info = { const result: Info = {
id: Identifier.descending("session"), id: Identifier.descending("session"),
title: "New Session - " + new Date().toISOString(), parentID,
title: "Child Session - " + new Date().toISOString(),
time: { time: {
created: Date.now(), created: Date.now(),
updated: Date.now(), updated: Date.now(),
@@ -91,11 +90,12 @@ export namespace Session {
log.info("created", result) log.info("created", result)
state().sessions.set(result.id, result) state().sessions.set(result.id, result)
await Storage.writeJSON("session/info/" + result.id, result) await Storage.writeJSON("session/info/" + result.id, result)
share(result.id).then((share) => { if (!result.parentID)
update(result.id, (draft) => { share(result.id).then((share) => {
draft.share = share update(result.id, (draft) => {
draft.share = share
})
}) })
})
Bus.publish(Event.Updated, { Bus.publish(Event.Updated, {
info: result, info: result,
}) })
@@ -186,12 +186,16 @@ export namespace Session {
providerID: string providerID: string
modelID: string modelID: string
parts: Message.Part[] parts: Message.Part[]
system?: string[]
tools?: Tool.Info[]
}) { }) {
const l = log.clone().tag("session", input.sessionID) const l = log.clone().tag("session", input.sessionID)
l.info("chatting") l.info("chatting")
const model = await Provider.getModel(input.providerID, input.modelID) const model = await Provider.getModel(input.providerID, input.modelID)
let msgs = await messages(input.sessionID) let msgs = await messages(input.sessionID)
const previous = msgs.at(-1) const previous = msgs.at(-1)
// auto summarize if too long
if (previous?.metadata.assistant) { if (previous?.metadata.assistant) {
const tokens = const tokens =
previous.metadata.assistant.tokens.input + previous.metadata.assistant.tokens.input +
@@ -214,95 +218,25 @@ export namespace Session {
const lastSummary = msgs.findLast( const lastSummary = msgs.findLast(
(msg) => msg.metadata.assistant?.summary === true, (msg) => msg.metadata.assistant?.summary === true,
) )
if (lastSummary) if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
msgs = msgs.filter(
(msg) => msg.role === "system" || msg.id >= lastSummary.id,
)
const app = App.info()
if (msgs.length === 0) { 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:`,
`<env>`,
`Working directory: ${app.path.cwd}`,
`Is directory a git repo: ${app.git ? "yes" : "no"}`,
`Platform: ${process.platform}`,
`Today's date: ${new Date().toISOString()}`,
`</env>`,
`<project>`,
`${app.git ? await ListTool.execute({ path: app.path.cwd, ignore: [] }, { sessionID: input.sessionID, abort: abort.signal }).then((x) => x.output) : ""}`,
`</project>`,
].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({ generateText({
maxOutputTokens: 20, maxOutputTokens: 20,
messages: convertToModelMessages([ messages: convertToModelMessages([
{ ...SystemPrompt.title(input.providerID).map(
role: "system", (x): UIMessage => ({
parts: [ id: Identifier.ascending("message"),
{ role: "system",
type: "text", parts: [
text: PROMPT_ANTHROPIC_SPOOF.trim(), {
}, type: "text",
], text: x,
}, },
{ ],
role: "system", }),
parts: [ ),
{
type: "text",
text: PROMPT_TITLE,
},
],
},
{ {
role: "user", role: "user",
parts: input.parts, parts: input.parts,
@@ -317,7 +251,6 @@ export namespace Session {
}) })
}) })
.catch(() => {}) .catch(() => {})
await updateMessage(system)
} }
const msg: Message.Info = { const msg: Message.Info = {
role: "user", role: "user",
@@ -334,12 +267,21 @@ export namespace Session {
await updateMessage(msg) await updateMessage(msg)
msgs.push(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 = { const next: Message.Info = {
id: Identifier.ascending("message"), id: Identifier.ascending("message"),
role: "assistant", role: "assistant",
parts: [], parts: [],
metadata: { metadata: {
assistant: { assistant: {
system,
path: {
cwd: app.path.cwd,
root: app.path.root,
},
cost: 0, cost: 0,
tokens: { tokens: {
input: 0, input: 0,
@@ -358,6 +300,7 @@ export namespace Session {
} }
await updateMessage(next) await updateMessage(next)
const tools: Record<string, AITool> = {} const tools: Record<string, AITool> = {}
for (const item of await Provider.tools(input.providerID)) { for (const item of await Provider.tools(input.providerID)) {
tools[item.id.replaceAll(".", "_")] = tool({ tools[item.id.replaceAll(".", "_")] = tool({
id: item.id as any, id: item.id as any,
@@ -369,6 +312,7 @@ export namespace Session {
const result = await item.execute(args, { const result = await item.execute(args, {
sessionID: input.sessionID, sessionID: input.sessionID,
abort: abort.signal, abort: abort.signal,
messageID: next.id,
}) })
next.metadata!.tool![opts.toolCallId] = { next.metadata!.tool![opts.toolCallId] = {
...result.metadata, ...result.metadata,
@@ -395,6 +339,7 @@ export namespace Session {
}, },
}) })
} }
for (const [key, item] of Object.entries(await MCP.tools())) { for (const [key, item] of Object.entries(await MCP.tools())) {
const execute = item.execute const execute = item.execute
if (!execute) continue if (!execute) continue
@@ -576,7 +521,21 @@ export namespace Session {
toolCallStreaming: true, toolCallStreaming: true,
abortSignal: abort.signal, abortSignal: abort.signal,
stopWhen: stepCountIs(1000), 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, temperature: model.info.id === "codex-mini-latest" ? undefined : 0,
tools: { tools: {
...(await MCP.tools()), ...(await MCP.tools()),
@@ -618,10 +577,11 @@ export namespace Session {
const lastSummary = msgs.findLast( const lastSummary = msgs.findLast(
(msg) => msg.metadata.assistant?.summary === true, (msg) => msg.metadata.assistant?.summary === true,
)?.id )?.id
const filtered = msgs.filter( const filtered = msgs.filter((msg) => !lastSummary || msg.id >= lastSummary)
(msg) => msg.role !== "system" && (!lastSummary || msg.id >= lastSummary),
)
const model = await Provider.getModel(input.providerID, input.modelID) const model = await Provider.getModel(input.providerID, input.modelID)
const app = App.info()
const system = SystemPrompt.summarize(input.providerID)
const next: Message.Info = { const next: Message.Info = {
id: Identifier.ascending("message"), id: Identifier.ascending("message"),
role: "assistant", role: "assistant",
@@ -630,6 +590,11 @@ export namespace Session {
tool: {}, tool: {},
sessionID: input.sessionID, sessionID: input.sessionID,
assistant: { assistant: {
system,
path: {
cwd: app.path.cwd,
root: app.path.root,
},
summary: true, summary: true,
cost: 0, cost: 0,
modelID: input.modelID, modelID: input.modelID,
@@ -650,15 +615,18 @@ export namespace Session {
abortSignal: abort.signal, abortSignal: abort.signal,
model: model.language, model: model.language,
messages: convertToModelMessages([ messages: convertToModelMessages([
{ ...system.map(
role: "system", (x): UIMessage => ({
parts: [ id: Identifier.ascending("message"),
{ role: "system",
type: "text", parts: [
text: PROMPT_SUMMARIZE, {
}, type: "text",
], text: x,
}, },
],
}),
),
...filtered, ...filtered,
{ {
role: "user", role: "user",

View File

@@ -133,7 +133,7 @@ export namespace Message {
export const Info = z export const Info = z
.object({ .object({
id: z.string(), id: z.string(),
role: z.enum(["system", "user", "assistant"]), role: z.enum(["user", "assistant"]),
parts: z.array(Part), parts: z.array(Part),
metadata: z.object({ metadata: z.object({
time: z.object({ time: z.object({
@@ -161,8 +161,13 @@ export namespace Message {
), ),
assistant: z assistant: z
.object({ .object({
system: z.string().array(),
modelID: z.string(), modelID: z.string(),
providerID: z.string(), providerID: z.string(),
path: z.object({
cwd: z.string(),
root: z.string(),
}),
cost: z.number(), cost: z.number(),
summary: z.boolean().optional(), summary: z.boolean().optional(),
tokens: z.object({ tokens: z.object({

View File

@@ -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:`,
`<env>`,
` Working directory: ${app.path.cwd}`,
` Is directory a git repo: ${app.git ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
`<project>`,
` ${app.git ? await ListTool.execute({ path: app.path.cwd, ignore: [] }, { sessionID: sessionID, messageID: "", abort: AbortSignal.any([]) }).then((x) => x.output) : ""}`,
`</project>`,
].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]
}
}
}

View File

@@ -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,
}
},
})

View File

@@ -7,6 +7,7 @@ export namespace Tool {
} }
export type Context = { export type Context = {
sessionID: string sessionID: string
messageID: string
abort: AbortSignal abort: AbortSignal
} }
export interface Info< export interface Info<