1475 lines
45 KiB
TypeScript
1475 lines
45 KiB
TypeScript
import path from "path"
|
|
import os from "os"
|
|
import fs from "fs/promises"
|
|
import z from "zod"
|
|
import { Identifier } from "../id/id"
|
|
import { MessageV2 } from "./message-v2"
|
|
import { Log } from "../util/log"
|
|
import { SessionRevert } from "./revert"
|
|
import { Session } from "."
|
|
import { Agent } from "../agent/agent"
|
|
import { Provider } from "../provider/provider"
|
|
import {
|
|
generateText,
|
|
streamText,
|
|
type ModelMessage,
|
|
type Tool as AITool,
|
|
tool,
|
|
wrapLanguageModel,
|
|
stepCountIs,
|
|
jsonSchema,
|
|
} from "ai"
|
|
import { SessionCompaction } from "./compaction"
|
|
import { Instance } from "../project/instance"
|
|
import { Bus } from "../bus"
|
|
import { ProviderTransform } from "../provider/transform"
|
|
import { SystemPrompt } from "./system"
|
|
import { Plugin } from "../plugin"
|
|
|
|
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
|
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
|
import { defer } from "../util/defer"
|
|
import { mergeDeep, pipe } from "remeda"
|
|
import { ToolRegistry } from "../tool/registry"
|
|
import { Wildcard } from "../util/wildcard"
|
|
import { MCP } from "../mcp"
|
|
import { LSP } from "../lsp"
|
|
import { ReadTool } from "../tool/read"
|
|
import { ListTool } from "../tool/ls"
|
|
import { FileTime } from "../file/time"
|
|
import { ulid } from "ulid"
|
|
import { spawn } from "child_process"
|
|
import { Command } from "../command"
|
|
import { $, fileURLToPath } from "bun"
|
|
import { ConfigMarkdown } from "../config/markdown"
|
|
import { SessionSummary } from "./summary"
|
|
import { NamedError } from "@opencode-ai/util/error"
|
|
import { fn } from "@/util/fn"
|
|
import { SessionProcessor } from "./processor"
|
|
import { TaskTool } from "@/tool/task"
|
|
import { SessionStatus } from "./status"
|
|
|
|
// @ts-ignore
|
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
|
|
|
export namespace SessionPrompt {
|
|
const log = Log.create({ service: "session.prompt" })
|
|
export const OUTPUT_TOKEN_MAX = 32_000
|
|
|
|
const state = Instance.state(
|
|
() => {
|
|
const data: Record<
|
|
string,
|
|
{
|
|
abort: AbortController
|
|
callbacks: {
|
|
resolve(input: MessageV2.WithParts): void
|
|
reject(): void
|
|
}[]
|
|
}
|
|
> = {}
|
|
return data
|
|
},
|
|
async (current) => {
|
|
for (const item of Object.values(current)) {
|
|
item.abort.abort()
|
|
}
|
|
},
|
|
)
|
|
|
|
export function assertNotBusy(sessionID: string) {
|
|
const match = state()[sessionID]
|
|
if (match) throw new Session.BusyError(sessionID)
|
|
}
|
|
|
|
export const PromptInput = z.object({
|
|
sessionID: Identifier.schema("session"),
|
|
messageID: Identifier.schema("message").optional(),
|
|
model: z
|
|
.object({
|
|
providerID: z.string(),
|
|
modelID: z.string(),
|
|
})
|
|
.optional(),
|
|
agent: z.string().optional(),
|
|
noReply: z.boolean().optional(),
|
|
system: z.string().optional(),
|
|
tools: z.record(z.string(), z.boolean()).optional(),
|
|
parts: z.array(
|
|
z.discriminatedUnion("type", [
|
|
MessageV2.TextPart.omit({
|
|
messageID: true,
|
|
sessionID: true,
|
|
})
|
|
.partial({
|
|
id: true,
|
|
})
|
|
.meta({
|
|
ref: "TextPartInput",
|
|
}),
|
|
MessageV2.FilePart.omit({
|
|
messageID: true,
|
|
sessionID: true,
|
|
})
|
|
.partial({
|
|
id: true,
|
|
})
|
|
.meta({
|
|
ref: "FilePartInput",
|
|
}),
|
|
MessageV2.AgentPart.omit({
|
|
messageID: true,
|
|
sessionID: true,
|
|
})
|
|
.partial({
|
|
id: true,
|
|
})
|
|
.meta({
|
|
ref: "AgentPartInput",
|
|
}),
|
|
MessageV2.SubtaskPart.omit({
|
|
messageID: true,
|
|
sessionID: true,
|
|
})
|
|
.partial({
|
|
id: true,
|
|
})
|
|
.meta({
|
|
ref: "SubtaskPartInput",
|
|
}),
|
|
]),
|
|
),
|
|
})
|
|
export type PromptInput = z.infer<typeof PromptInput>
|
|
|
|
export async function resolvePromptParts(template: string): Promise<PromptInput["parts"]> {
|
|
const parts: PromptInput["parts"] = [
|
|
{
|
|
type: "text",
|
|
text: template,
|
|
},
|
|
]
|
|
const files = ConfigMarkdown.files(template)
|
|
await Promise.all(
|
|
files.map(async (match) => {
|
|
const name = match[1]
|
|
const filepath = name.startsWith("~/")
|
|
? path.join(os.homedir(), name.slice(2))
|
|
: path.resolve(Instance.worktree, name)
|
|
|
|
const stats = await fs.stat(filepath).catch(() => undefined)
|
|
if (!stats) {
|
|
const agent = await Agent.get(name)
|
|
if (agent) {
|
|
parts.push({
|
|
type: "agent",
|
|
name: agent.name,
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
if (stats.isDirectory()) {
|
|
parts.push({
|
|
type: "file",
|
|
url: `file://${filepath}`,
|
|
filename: name,
|
|
mime: "application/x-directory",
|
|
})
|
|
return
|
|
}
|
|
|
|
parts.push({
|
|
type: "file",
|
|
url: `file://${filepath}`,
|
|
filename: name,
|
|
mime: "text/plain",
|
|
})
|
|
}),
|
|
)
|
|
return parts
|
|
}
|
|
|
|
export const prompt = fn(PromptInput, async (input) => {
|
|
const session = await Session.get(input.sessionID)
|
|
await SessionRevert.cleanup(session)
|
|
|
|
const message = await createUserMessage(input)
|
|
await Session.touch(input.sessionID)
|
|
|
|
if (input.noReply === true) {
|
|
return message
|
|
}
|
|
|
|
return loop(input.sessionID)
|
|
})
|
|
|
|
function start(sessionID: string) {
|
|
const s = state()
|
|
if (s[sessionID]) return
|
|
const controller = new AbortController()
|
|
s[sessionID] = {
|
|
abort: controller,
|
|
callbacks: [],
|
|
}
|
|
return controller.signal
|
|
}
|
|
|
|
export function cancel(sessionID: string) {
|
|
log.info("cancel", { sessionID })
|
|
const s = state()
|
|
const match = s[sessionID]
|
|
if (!match) return
|
|
match.abort.abort()
|
|
for (const item of match.callbacks) {
|
|
item.reject()
|
|
}
|
|
delete s[sessionID]
|
|
SessionStatus.set(sessionID, { type: "idle" })
|
|
return
|
|
}
|
|
|
|
export const loop = fn(Identifier.schema("session"), async (sessionID) => {
|
|
const abort = start(sessionID)
|
|
if (!abort) {
|
|
return new Promise<MessageV2.WithParts>((resolve, reject) => {
|
|
const callbacks = state()[sessionID].callbacks
|
|
callbacks.push({ resolve, reject })
|
|
})
|
|
}
|
|
|
|
using _ = defer(() => cancel(sessionID))
|
|
|
|
let step = 0
|
|
while (true) {
|
|
SessionStatus.set(sessionID, { type: "busy" })
|
|
log.info("loop", { step, sessionID })
|
|
if (abort.aborted) break
|
|
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
|
|
|
let lastUser: MessageV2.User | undefined
|
|
let lastAssistant: MessageV2.Assistant | undefined
|
|
let lastFinished: MessageV2.Assistant | undefined
|
|
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
|
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
const msg = msgs[i]
|
|
if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User
|
|
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant
|
|
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
|
|
lastFinished = msg.info as MessageV2.Assistant
|
|
if (lastUser && lastFinished) break
|
|
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
|
|
if (task && !lastFinished) {
|
|
tasks.push(...task)
|
|
}
|
|
}
|
|
|
|
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
|
|
if (
|
|
lastAssistant?.finish &&
|
|
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
|
|
lastUser.id < lastAssistant.id
|
|
) {
|
|
log.info("exiting loop", { sessionID })
|
|
break
|
|
}
|
|
|
|
step++
|
|
if (step === 1)
|
|
ensureTitle({
|
|
session: await Session.get(sessionID),
|
|
modelID: lastUser.model.modelID,
|
|
providerID: lastUser.model.providerID,
|
|
message: msgs.find((m) => m.info.role === "user")!,
|
|
history: msgs,
|
|
})
|
|
|
|
const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID)
|
|
const task = tasks.pop()
|
|
|
|
// pending subtask
|
|
// TODO: centralize "invoke tool" logic
|
|
if (task?.type === "subtask") {
|
|
const taskTool = await TaskTool.init()
|
|
const assistantMessage = (await Session.updateMessage({
|
|
id: Identifier.ascending("message"),
|
|
role: "assistant",
|
|
parentID: lastUser.id,
|
|
sessionID,
|
|
mode: task.agent,
|
|
path: {
|
|
cwd: Instance.directory,
|
|
root: Instance.worktree,
|
|
},
|
|
cost: 0,
|
|
tokens: {
|
|
input: 0,
|
|
output: 0,
|
|
reasoning: 0,
|
|
cache: { read: 0, write: 0 },
|
|
},
|
|
modelID: model.modelID,
|
|
providerID: model.providerID,
|
|
time: {
|
|
created: Date.now(),
|
|
},
|
|
})) as MessageV2.Assistant
|
|
let part = (await Session.updatePart({
|
|
id: Identifier.ascending("part"),
|
|
messageID: assistantMessage.id,
|
|
sessionID: assistantMessage.sessionID,
|
|
type: "tool",
|
|
callID: ulid(),
|
|
tool: TaskTool.id,
|
|
state: {
|
|
status: "running",
|
|
input: {
|
|
prompt: task.prompt,
|
|
description: task.description,
|
|
subagent_type: task.agent,
|
|
},
|
|
time: {
|
|
start: Date.now(),
|
|
},
|
|
},
|
|
})) as MessageV2.ToolPart
|
|
const result = await taskTool
|
|
.execute(
|
|
{
|
|
prompt: task.prompt,
|
|
description: task.description,
|
|
subagent_type: task.agent,
|
|
},
|
|
{
|
|
agent: task.agent,
|
|
messageID: assistantMessage.id,
|
|
sessionID: sessionID,
|
|
abort,
|
|
async metadata(input) {
|
|
await Session.updatePart({
|
|
...part,
|
|
type: "tool",
|
|
state: {
|
|
...part.state,
|
|
...input,
|
|
},
|
|
} satisfies MessageV2.ToolPart)
|
|
},
|
|
},
|
|
)
|
|
.catch(() => {})
|
|
assistantMessage.finish = "tool-calls"
|
|
assistantMessage.time.completed = Date.now()
|
|
await Session.updateMessage(assistantMessage)
|
|
if (result && part.state.status === "running") {
|
|
await Session.updatePart({
|
|
...part,
|
|
state: {
|
|
status: "completed",
|
|
input: part.state.input,
|
|
title: result.title,
|
|
metadata: result.metadata,
|
|
output: result.output,
|
|
attachments: result.attachments,
|
|
time: {
|
|
...part.state.time,
|
|
end: Date.now(),
|
|
},
|
|
},
|
|
} satisfies MessageV2.ToolPart)
|
|
}
|
|
if (!result) {
|
|
await Session.updatePart({
|
|
...part,
|
|
state: {
|
|
status: "error",
|
|
error: "Tool execution failed",
|
|
time: {
|
|
start: part.state.status === "running" ? part.state.time.start : Date.now(),
|
|
end: Date.now(),
|
|
},
|
|
metadata: part.metadata,
|
|
input: part.state.input,
|
|
},
|
|
} satisfies MessageV2.ToolPart)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// pending compaction
|
|
if (task?.type === "compaction") {
|
|
const result = await SessionCompaction.process({
|
|
messages: msgs,
|
|
parentID: lastUser.id,
|
|
abort,
|
|
agent: lastUser.agent,
|
|
model: {
|
|
providerID: model.providerID,
|
|
modelID: model.modelID,
|
|
},
|
|
sessionID,
|
|
})
|
|
if (result === "stop") break
|
|
continue
|
|
}
|
|
|
|
// context overflow, needs compaction
|
|
if (
|
|
lastFinished &&
|
|
lastFinished.summary !== true &&
|
|
SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model: model.info })
|
|
) {
|
|
await SessionCompaction.create({
|
|
sessionID,
|
|
agent: lastUser.agent,
|
|
model: lastUser.model,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// normal processing
|
|
const agent = await Agent.get(lastUser.agent)
|
|
msgs = insertReminders({
|
|
messages: msgs,
|
|
agent,
|
|
})
|
|
const processor = SessionProcessor.create({
|
|
assistantMessage: (await Session.updateMessage({
|
|
id: Identifier.ascending("message"),
|
|
parentID: lastUser.id,
|
|
role: "assistant",
|
|
mode: agent.name,
|
|
path: {
|
|
cwd: Instance.directory,
|
|
root: Instance.worktree,
|
|
},
|
|
cost: 0,
|
|
tokens: {
|
|
input: 0,
|
|
output: 0,
|
|
reasoning: 0,
|
|
cache: { read: 0, write: 0 },
|
|
},
|
|
modelID: model.modelID,
|
|
providerID: model.providerID,
|
|
time: {
|
|
created: Date.now(),
|
|
},
|
|
sessionID,
|
|
})) as MessageV2.Assistant,
|
|
sessionID: sessionID,
|
|
model: model.info,
|
|
providerID: model.providerID,
|
|
abort,
|
|
})
|
|
const system = await resolveSystemPrompt({
|
|
providerID: model.providerID,
|
|
modelID: model.info.id,
|
|
agent,
|
|
system: lastUser.system,
|
|
})
|
|
const tools = await resolveTools({
|
|
agent,
|
|
sessionID,
|
|
model: lastUser.model,
|
|
tools: lastUser.tools,
|
|
processor,
|
|
})
|
|
const params = await Plugin.trigger(
|
|
"chat.params",
|
|
{
|
|
sessionID: sessionID,
|
|
agent: lastUser.agent,
|
|
model: model.info,
|
|
provider: await Provider.getProvider(model.providerID),
|
|
message: lastUser,
|
|
},
|
|
{
|
|
temperature: model.info.temperature
|
|
? (agent.temperature ?? ProviderTransform.temperature(model.providerID, model.modelID))
|
|
: undefined,
|
|
topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID),
|
|
options: pipe(
|
|
{},
|
|
mergeDeep(ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", sessionID)),
|
|
mergeDeep(model.info.options),
|
|
mergeDeep(agent.options),
|
|
),
|
|
},
|
|
)
|
|
|
|
if (step === 1) {
|
|
SessionSummary.summarize({
|
|
sessionID: sessionID,
|
|
messageID: lastUser.id,
|
|
})
|
|
}
|
|
|
|
const result = await processor.process(() =>
|
|
streamText({
|
|
onError(error) {
|
|
log.error("stream error", {
|
|
error,
|
|
})
|
|
},
|
|
async experimental_repairToolCall(input) {
|
|
const lower = input.toolCall.toolName.toLowerCase()
|
|
if (lower !== input.toolCall.toolName && tools[lower]) {
|
|
log.info("repairing tool call", {
|
|
tool: input.toolCall.toolName,
|
|
repaired: lower,
|
|
})
|
|
return {
|
|
...input.toolCall,
|
|
toolName: lower,
|
|
}
|
|
}
|
|
return {
|
|
...input.toolCall,
|
|
input: JSON.stringify({
|
|
tool: input.toolCall.toolName,
|
|
error: input.error.message,
|
|
}),
|
|
toolName: "invalid",
|
|
}
|
|
},
|
|
headers: {
|
|
...(model.providerID.startsWith("opencode")
|
|
? {
|
|
"x-opencode-session": sessionID,
|
|
"x-opencode-request": lastUser.id,
|
|
}
|
|
: undefined),
|
|
...model.info.headers,
|
|
},
|
|
// set to 0, we handle loop
|
|
maxRetries: 0,
|
|
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
|
|
maxOutputTokens: ProviderTransform.maxOutputTokens(
|
|
model.providerID,
|
|
params.options,
|
|
model.info.limit.output,
|
|
OUTPUT_TOKEN_MAX,
|
|
),
|
|
abortSignal: abort,
|
|
providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, params.options),
|
|
stopWhen: stepCountIs(1),
|
|
temperature: params.temperature,
|
|
topP: params.topP,
|
|
messages: [
|
|
...system.map(
|
|
(x): ModelMessage => ({
|
|
role: "system",
|
|
content: x,
|
|
}),
|
|
),
|
|
...MessageV2.toModelMessage(
|
|
msgs.filter((m) => {
|
|
if (m.info.role !== "assistant" || m.info.error === undefined) {
|
|
return true
|
|
}
|
|
if (
|
|
MessageV2.AbortedError.isInstance(m.info.error) &&
|
|
m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
|
|
) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}),
|
|
),
|
|
],
|
|
tools: model.info.tool_call === false ? undefined : tools,
|
|
model: wrapLanguageModel({
|
|
model: model.language,
|
|
middleware: [
|
|
{
|
|
async transformParams(args) {
|
|
if (args.type === "stream") {
|
|
// @ts-expect-error
|
|
args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID)
|
|
}
|
|
return args.params
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
)
|
|
if (result === "stop") break
|
|
continue
|
|
}
|
|
SessionCompaction.prune({ sessionID })
|
|
for await (const item of MessageV2.stream(sessionID)) {
|
|
if (item.info.role === "user") continue
|
|
const queued = state()[sessionID]?.callbacks ?? []
|
|
for (const q of queued) {
|
|
q.resolve(item)
|
|
}
|
|
return item
|
|
}
|
|
throw new Error("Impossible")
|
|
})
|
|
|
|
async function lastModel(sessionID: string) {
|
|
for await (const item of MessageV2.stream(sessionID)) {
|
|
if (item.info.role === "user" && item.info.model) return item.info.model
|
|
}
|
|
return Provider.defaultModel()
|
|
}
|
|
|
|
async function resolveSystemPrompt(input: {
|
|
system?: string
|
|
agent: Agent.Info
|
|
providerID: string
|
|
modelID: string
|
|
}) {
|
|
let system = SystemPrompt.header(input.providerID)
|
|
system.push(
|
|
...(() => {
|
|
if (input.system) return [input.system]
|
|
if (input.agent.prompt) return [input.agent.prompt]
|
|
return SystemPrompt.provider(input.modelID)
|
|
})(),
|
|
)
|
|
system.push(...(await SystemPrompt.environment()))
|
|
system.push(...(await SystemPrompt.custom()))
|
|
// max 2 system prompt messages for caching purposes
|
|
const [first, ...rest] = system
|
|
system = [first, rest.join("\n")]
|
|
return system
|
|
}
|
|
|
|
async function resolveTools(input: {
|
|
agent: Agent.Info
|
|
model: {
|
|
providerID: string
|
|
modelID: string
|
|
}
|
|
sessionID: string
|
|
tools?: Record<string, boolean>
|
|
processor: SessionProcessor.Info
|
|
}) {
|
|
const tools: Record<string, AITool> = {}
|
|
const enabledTools = pipe(
|
|
input.agent.tools,
|
|
mergeDeep(await ToolRegistry.enabled(input.model.providerID, input.model.modelID, input.agent)),
|
|
mergeDeep(input.tools ?? {}),
|
|
)
|
|
for (const item of await ToolRegistry.tools(input.model.providerID, input.model.modelID)) {
|
|
if (Wildcard.all(item.id, enabledTools) === false) continue
|
|
const schema = ProviderTransform.schema(
|
|
input.model.providerID,
|
|
input.model.modelID,
|
|
z.toJSONSchema(item.parameters),
|
|
)
|
|
tools[item.id] = tool({
|
|
id: item.id as any,
|
|
description: item.description,
|
|
inputSchema: jsonSchema(schema as any),
|
|
async execute(args, options) {
|
|
await Plugin.trigger(
|
|
"tool.execute.before",
|
|
{
|
|
tool: item.id,
|
|
sessionID: input.sessionID,
|
|
callID: options.toolCallId,
|
|
},
|
|
{
|
|
args,
|
|
},
|
|
)
|
|
const result = await item.execute(args, {
|
|
sessionID: input.sessionID,
|
|
abort: options.abortSignal!,
|
|
messageID: input.processor.message.id,
|
|
callID: options.toolCallId,
|
|
extra: input.model,
|
|
agent: input.agent.name,
|
|
metadata: async (val) => {
|
|
const match = input.processor.partFromToolCall(options.toolCallId)
|
|
if (match && match.state.status === "running") {
|
|
await Session.updatePart({
|
|
...match,
|
|
state: {
|
|
title: val.title,
|
|
metadata: val.metadata,
|
|
status: "running",
|
|
input: args,
|
|
time: {
|
|
start: Date.now(),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
},
|
|
})
|
|
await Plugin.trigger(
|
|
"tool.execute.after",
|
|
{
|
|
tool: item.id,
|
|
sessionID: input.sessionID,
|
|
callID: options.toolCallId,
|
|
},
|
|
result,
|
|
)
|
|
return result
|
|
},
|
|
toModelOutput(result) {
|
|
return {
|
|
type: "text",
|
|
value: result.output,
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
for (const [key, item] of Object.entries(await MCP.tools())) {
|
|
if (Wildcard.all(key, enabledTools) === false) continue
|
|
const execute = item.execute
|
|
if (!execute) continue
|
|
item.execute = async (args, opts) => {
|
|
await Plugin.trigger(
|
|
"tool.execute.before",
|
|
{
|
|
tool: key,
|
|
sessionID: input.sessionID,
|
|
callID: opts.toolCallId,
|
|
},
|
|
{
|
|
args,
|
|
},
|
|
)
|
|
const result = await execute(args, opts)
|
|
|
|
await Plugin.trigger(
|
|
"tool.execute.after",
|
|
{
|
|
tool: key,
|
|
sessionID: input.sessionID,
|
|
callID: opts.toolCallId,
|
|
},
|
|
result,
|
|
)
|
|
|
|
const textParts: string[] = []
|
|
const attachments: MessageV2.FilePart[] = []
|
|
|
|
for (const item of result.content) {
|
|
if (item.type === "text") {
|
|
textParts.push(item.text)
|
|
} else if (item.type === "image") {
|
|
attachments.push({
|
|
id: Identifier.ascending("part"),
|
|
sessionID: input.sessionID,
|
|
messageID: input.processor.message.id,
|
|
type: "file",
|
|
mime: item.mimeType,
|
|
url: `data:${item.mimeType};base64,${item.data}`,
|
|
})
|
|
}
|
|
// Add support for other types if needed
|
|
}
|
|
|
|
return {
|
|
title: "",
|
|
metadata: result.metadata ?? {},
|
|
output: textParts.join("\n\n"),
|
|
attachments,
|
|
content: result.content, // directly return content to preserve ordering when outputting to model
|
|
}
|
|
}
|
|
item.toModelOutput = (result) => {
|
|
return {
|
|
type: "text",
|
|
value: result.output,
|
|
}
|
|
}
|
|
tools[key] = item
|
|
}
|
|
return tools
|
|
}
|
|
|
|
async function createUserMessage(input: PromptInput) {
|
|
const agent = await Agent.get(input.agent ?? "build")
|
|
const info: MessageV2.Info = {
|
|
id: input.messageID ?? Identifier.ascending("message"),
|
|
role: "user",
|
|
sessionID: input.sessionID,
|
|
time: {
|
|
created: Date.now(),
|
|
},
|
|
tools: input.tools,
|
|
system: input.system,
|
|
agent: agent.name,
|
|
model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
|
|
}
|
|
|
|
const parts = await Promise.all(
|
|
input.parts.map(async (part): Promise<MessageV2.Part[]> => {
|
|
if (part.type === "file") {
|
|
const url = new URL(part.url)
|
|
switch (url.protocol) {
|
|
case "data:":
|
|
if (part.mime === "text/plain") {
|
|
return [
|
|
{
|
|
id: Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
type: "text",
|
|
synthetic: true,
|
|
text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
|
|
},
|
|
{
|
|
id: Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
type: "text",
|
|
synthetic: true,
|
|
text: Buffer.from(part.url, "base64url").toString(),
|
|
},
|
|
{
|
|
...part,
|
|
id: part.id ?? Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
},
|
|
]
|
|
}
|
|
break
|
|
case "file:":
|
|
log.info("file", { mime: part.mime })
|
|
// have to normalize, symbol search returns absolute paths
|
|
// Decode the pathname since URL constructor doesn't automatically decode it
|
|
const filepath = fileURLToPath(part.url)
|
|
const stat = await Bun.file(filepath).stat()
|
|
|
|
if (stat.isDirectory()) {
|
|
part.mime = "application/x-directory"
|
|
}
|
|
|
|
if (part.mime === "text/plain") {
|
|
let offset: number | undefined = undefined
|
|
let limit: number | undefined = undefined
|
|
const range = {
|
|
start: url.searchParams.get("start"),
|
|
end: url.searchParams.get("end"),
|
|
}
|
|
if (range.start != null) {
|
|
const filePathURI = part.url.split("?")[0]
|
|
let start = parseInt(range.start)
|
|
let end = range.end ? parseInt(range.end) : undefined
|
|
// some LSP servers (eg, gopls) don't give full range in
|
|
// workspace/symbol searches, so we'll try to find the
|
|
// symbol in the document to get the full range
|
|
if (start === end) {
|
|
const symbols = await LSP.documentSymbol(filePathURI)
|
|
for (const symbol of symbols) {
|
|
let range: LSP.Range | undefined
|
|
if ("range" in symbol) {
|
|
range = symbol.range
|
|
} else if ("location" in symbol) {
|
|
range = symbol.location.range
|
|
}
|
|
if (range?.start?.line && range?.start?.line === start) {
|
|
start = range.start.line
|
|
end = range?.end?.line ?? start
|
|
break
|
|
}
|
|
}
|
|
}
|
|
offset = Math.max(start - 1, 0)
|
|
if (end) {
|
|
limit = end - offset
|
|
}
|
|
}
|
|
const args = { filePath: filepath, offset, limit }
|
|
|
|
const pieces: MessageV2.Part[] = [
|
|
{
|
|
id: Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
type: "text",
|
|
synthetic: true,
|
|
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
|
},
|
|
]
|
|
|
|
await ReadTool.init()
|
|
.then(async (t) => {
|
|
const result = await t.execute(args, {
|
|
sessionID: input.sessionID,
|
|
abort: new AbortController().signal,
|
|
agent: input.agent!,
|
|
messageID: info.id,
|
|
extra: { bypassCwdCheck: true, ...info.model },
|
|
metadata: async () => {},
|
|
})
|
|
pieces.push({
|
|
id: Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
type: "text",
|
|
synthetic: true,
|
|
text: result.output,
|
|
})
|
|
if (result.attachments?.length) {
|
|
pieces.push(
|
|
...result.attachments.map((attachment) => ({
|
|
...attachment,
|
|
synthetic: true,
|
|
filename: attachment.filename ?? part.filename,
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
})),
|
|
)
|
|
} else {
|
|
pieces.push({
|
|
...part,
|
|
id: part.id ?? Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
})
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
log.error("failed to read file", { error })
|
|
const message = error instanceof Error ? error.message : error.toString()
|
|
Bus.publish(Session.Event.Error, {
|
|
sessionID: input.sessionID,
|
|
error: new NamedError.Unknown({
|
|
message,
|
|
}).toObject(),
|
|
})
|
|
pieces.push({
|
|
id: Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
type: "text",
|
|
synthetic: true,
|
|
text: `Read tool failed to read ${filepath} with the following error: ${message}`,
|
|
})
|
|
})
|
|
|
|
return pieces
|
|
}
|
|
|
|
if (part.mime === "application/x-directory") {
|
|
const args = { path: filepath }
|
|
const result = await ListTool.init().then((t) =>
|
|
t.execute(args, {
|
|
sessionID: input.sessionID,
|
|
abort: new AbortController().signal,
|
|
agent: input.agent!,
|
|
messageID: info.id,
|
|
extra: { bypassCwdCheck: true },
|
|
metadata: async () => {},
|
|
}),
|
|
)
|
|
return [
|
|
{
|
|
id: Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
type: "text",
|
|
synthetic: true,
|
|
text: `Called the list tool with the following input: ${JSON.stringify(args)}`,
|
|
},
|
|
{
|
|
id: Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
type: "text",
|
|
synthetic: true,
|
|
text: result.output,
|
|
},
|
|
{
|
|
...part,
|
|
id: part.id ?? Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
},
|
|
]
|
|
}
|
|
|
|
const file = Bun.file(filepath)
|
|
FileTime.read(input.sessionID, filepath)
|
|
return [
|
|
{
|
|
id: Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
type: "text",
|
|
text: `Called the Read tool with the following input: {\"filePath\":\"${filepath}\"}`,
|
|
synthetic: true,
|
|
},
|
|
{
|
|
id: part.id ?? Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
type: "file",
|
|
url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"),
|
|
mime: part.mime,
|
|
filename: part.filename!,
|
|
source: part.source,
|
|
},
|
|
]
|
|
}
|
|
}
|
|
|
|
if (part.type === "agent") {
|
|
return [
|
|
{
|
|
id: Identifier.ascending("part"),
|
|
...part,
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
},
|
|
{
|
|
id: Identifier.ascending("part"),
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
type: "text",
|
|
synthetic: true,
|
|
text:
|
|
"Use the above message and context to generate a prompt and call the task tool with subagent: " +
|
|
part.name,
|
|
},
|
|
]
|
|
}
|
|
|
|
return [
|
|
{
|
|
id: Identifier.ascending("part"),
|
|
...part,
|
|
messageID: info.id,
|
|
sessionID: input.sessionID,
|
|
},
|
|
]
|
|
}),
|
|
).then((x) => x.flat())
|
|
|
|
await Plugin.trigger(
|
|
"chat.message",
|
|
{
|
|
sessionID: input.sessionID,
|
|
agent: input.agent,
|
|
model: input.model,
|
|
messageID: input.messageID,
|
|
},
|
|
{
|
|
message: info,
|
|
parts,
|
|
},
|
|
)
|
|
|
|
await Session.updateMessage(info)
|
|
for (const part of parts) {
|
|
await Session.updatePart(part)
|
|
}
|
|
|
|
return {
|
|
info,
|
|
parts,
|
|
}
|
|
}
|
|
|
|
function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info }) {
|
|
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
|
|
if (!userMessage) return input.messages
|
|
if (input.agent.name === "plan") {
|
|
userMessage.parts.push({
|
|
id: Identifier.ascending("part"),
|
|
messageID: userMessage.info.id,
|
|
sessionID: userMessage.info.sessionID,
|
|
type: "text",
|
|
text: PROMPT_PLAN,
|
|
synthetic: true,
|
|
})
|
|
}
|
|
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.mode === "plan")
|
|
if (wasPlan && input.agent.name === "build") {
|
|
userMessage.parts.push({
|
|
id: Identifier.ascending("part"),
|
|
messageID: userMessage.info.id,
|
|
sessionID: userMessage.info.sessionID,
|
|
type: "text",
|
|
text: BUILD_SWITCH,
|
|
synthetic: true,
|
|
})
|
|
}
|
|
return input.messages
|
|
}
|
|
|
|
export const ShellInput = z.object({
|
|
sessionID: Identifier.schema("session"),
|
|
agent: z.string(),
|
|
model: z
|
|
.object({
|
|
providerID: z.string(),
|
|
modelID: z.string(),
|
|
})
|
|
.optional(),
|
|
command: z.string(),
|
|
})
|
|
export type ShellInput = z.infer<typeof ShellInput>
|
|
export async function shell(input: ShellInput) {
|
|
const session = await Session.get(input.sessionID)
|
|
if (session.revert) {
|
|
SessionRevert.cleanup(session)
|
|
}
|
|
const agent = await Agent.get(input.agent)
|
|
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
|
|
const userMsg: MessageV2.User = {
|
|
id: Identifier.ascending("message"),
|
|
sessionID: input.sessionID,
|
|
time: {
|
|
created: Date.now(),
|
|
},
|
|
role: "user",
|
|
agent: input.agent,
|
|
model: {
|
|
providerID: model.providerID,
|
|
modelID: model.modelID,
|
|
},
|
|
}
|
|
await Session.updateMessage(userMsg)
|
|
const userPart: MessageV2.Part = {
|
|
type: "text",
|
|
id: Identifier.ascending("part"),
|
|
messageID: userMsg.id,
|
|
sessionID: input.sessionID,
|
|
text: "The following tool was executed by the user",
|
|
synthetic: true,
|
|
}
|
|
await Session.updatePart(userPart)
|
|
|
|
const msg: MessageV2.Assistant = {
|
|
id: Identifier.ascending("message"),
|
|
sessionID: input.sessionID,
|
|
parentID: userMsg.id,
|
|
mode: input.agent,
|
|
cost: 0,
|
|
path: {
|
|
cwd: Instance.directory,
|
|
root: Instance.worktree,
|
|
},
|
|
time: {
|
|
created: Date.now(),
|
|
},
|
|
role: "assistant",
|
|
tokens: {
|
|
input: 0,
|
|
output: 0,
|
|
reasoning: 0,
|
|
cache: { read: 0, write: 0 },
|
|
},
|
|
modelID: model.modelID,
|
|
providerID: model.providerID,
|
|
}
|
|
await Session.updateMessage(msg)
|
|
const part: MessageV2.Part = {
|
|
type: "tool",
|
|
id: Identifier.ascending("part"),
|
|
messageID: msg.id,
|
|
sessionID: input.sessionID,
|
|
tool: "bash",
|
|
callID: ulid(),
|
|
state: {
|
|
status: "running",
|
|
time: {
|
|
start: Date.now(),
|
|
},
|
|
input: {
|
|
command: input.command,
|
|
},
|
|
},
|
|
}
|
|
await Session.updatePart(part)
|
|
const shell = process.env["SHELL"] ?? "bash"
|
|
const shellName = path.basename(shell)
|
|
|
|
const invocations: Record<string, { args: string[] }> = {
|
|
nu: {
|
|
args: ["-c", input.command],
|
|
},
|
|
fish: {
|
|
args: ["-c", input.command],
|
|
},
|
|
zsh: {
|
|
args: [
|
|
"-c",
|
|
"-l",
|
|
`
|
|
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
|
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
|
${input.command}
|
|
`,
|
|
],
|
|
},
|
|
bash: {
|
|
args: [
|
|
"-c",
|
|
"-l",
|
|
`
|
|
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
|
${input.command}
|
|
`,
|
|
],
|
|
},
|
|
// Fallback: any shell that doesn't match those above
|
|
"": {
|
|
args: ["-c", "-l", `${input.command}`],
|
|
},
|
|
}
|
|
|
|
const matchingInvocation = invocations[shellName] ?? invocations[""]
|
|
const args = matchingInvocation?.args
|
|
|
|
const proc = spawn(shell, args, {
|
|
cwd: Instance.directory,
|
|
detached: true,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: {
|
|
...process.env,
|
|
TERM: "dumb",
|
|
},
|
|
})
|
|
|
|
let output = ""
|
|
|
|
proc.stdout?.on("data", (chunk) => {
|
|
output += chunk.toString()
|
|
if (part.state.status === "running") {
|
|
part.state.metadata = {
|
|
output: output,
|
|
description: "",
|
|
}
|
|
Session.updatePart(part)
|
|
}
|
|
})
|
|
|
|
proc.stderr?.on("data", (chunk) => {
|
|
output += chunk.toString()
|
|
if (part.state.status === "running") {
|
|
part.state.metadata = {
|
|
output: output,
|
|
description: "",
|
|
}
|
|
Session.updatePart(part)
|
|
}
|
|
})
|
|
|
|
await new Promise<void>((resolve) => {
|
|
proc.on("close", () => {
|
|
resolve()
|
|
})
|
|
})
|
|
msg.time.completed = Date.now()
|
|
await Session.updateMessage(msg)
|
|
if (part.state.status === "running") {
|
|
part.state = {
|
|
status: "completed",
|
|
time: {
|
|
...part.state.time,
|
|
end: Date.now(),
|
|
},
|
|
input: part.state.input,
|
|
title: "",
|
|
metadata: {
|
|
output,
|
|
description: "",
|
|
},
|
|
output,
|
|
}
|
|
await Session.updatePart(part)
|
|
}
|
|
return { info: msg, parts: [part] }
|
|
}
|
|
|
|
export const CommandInput = z.object({
|
|
messageID: Identifier.schema("message").optional(),
|
|
sessionID: Identifier.schema("session"),
|
|
agent: z.string().optional(),
|
|
model: z.string().optional(),
|
|
arguments: z.string(),
|
|
command: z.string(),
|
|
})
|
|
export type CommandInput = z.infer<typeof CommandInput>
|
|
const bashRegex = /!`([^`]+)`/g
|
|
const argsRegex = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g
|
|
const placeholderRegex = /\$(\d+)/g
|
|
const quoteTrimRegex = /^["']|["']$/g
|
|
/**
|
|
* Regular expression to match @ file references in text
|
|
* Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks
|
|
* Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references)
|
|
*/
|
|
|
|
export async function command(input: CommandInput) {
|
|
log.info("command", input)
|
|
const command = await Command.get(input.command)
|
|
const agentName = command.agent ?? input.agent ?? "build"
|
|
|
|
const raw = input.arguments.match(argsRegex) ?? []
|
|
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
|
|
|
|
const placeholders = command.template.match(placeholderRegex) ?? []
|
|
let last = 0
|
|
for (const item of placeholders) {
|
|
const value = Number(item.slice(1))
|
|
if (value > last) last = value
|
|
}
|
|
|
|
// Let the final placeholder swallow any extra arguments so prompts read naturally
|
|
const withArgs = command.template.replaceAll(placeholderRegex, (_, index) => {
|
|
const position = Number(index)
|
|
const argIndex = position - 1
|
|
if (argIndex >= args.length) return ""
|
|
if (position === last) return args.slice(argIndex).join(" ")
|
|
return args[argIndex]
|
|
})
|
|
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
|
|
|
|
const shell = ConfigMarkdown.shell(template)
|
|
if (shell.length > 0) {
|
|
const results = await Promise.all(
|
|
shell.map(async ([, cmd]) => {
|
|
try {
|
|
return await $`${{ raw: cmd }}`.nothrow().text()
|
|
} catch (error) {
|
|
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
|
|
}
|
|
}),
|
|
)
|
|
let index = 0
|
|
template = template.replace(bashRegex, () => results[index++])
|
|
}
|
|
template = template.trim()
|
|
|
|
const model = await (async () => {
|
|
if (command.model) {
|
|
return Provider.parseModel(command.model)
|
|
}
|
|
if (command.agent) {
|
|
const cmdAgent = await Agent.get(command.agent)
|
|
if (cmdAgent.model) {
|
|
return cmdAgent.model
|
|
}
|
|
}
|
|
if (input.model) return Provider.parseModel(input.model)
|
|
return await lastModel(input.sessionID)
|
|
})()
|
|
const agent = await Agent.get(agentName)
|
|
|
|
const parts =
|
|
(agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
|
|
? [
|
|
{
|
|
type: "subtask" as const,
|
|
agent: agent.name,
|
|
description: command.description ?? "",
|
|
// TODO: how can we make task tool accept a more complex input?
|
|
prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""),
|
|
},
|
|
]
|
|
: await resolvePromptParts(template)
|
|
|
|
const result = (await prompt({
|
|
sessionID: input.sessionID,
|
|
messageID: input.messageID,
|
|
model,
|
|
agent: agentName,
|
|
parts,
|
|
})) as MessageV2.WithParts
|
|
|
|
Bus.publish(Command.Event.Executed, {
|
|
name: input.command,
|
|
sessionID: input.sessionID,
|
|
arguments: input.arguments,
|
|
messageID: result.info.id,
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
async function ensureTitle(input: {
|
|
session: Session.Info
|
|
message: MessageV2.WithParts
|
|
history: MessageV2.WithParts[]
|
|
providerID: string
|
|
modelID: string
|
|
}) {
|
|
if (input.session.parentID) return
|
|
if (!Session.isDefaultTitle(input.session.title)) return
|
|
const isFirst =
|
|
input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic))
|
|
.length === 1
|
|
if (!isFirst) return
|
|
const small =
|
|
(await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
|
|
const options = pipe(
|
|
{},
|
|
mergeDeep(ProviderTransform.options(small.providerID, small.modelID, small.npm ?? "", input.session.id)),
|
|
mergeDeep(ProviderTransform.smallOptions({ providerID: small.providerID, modelID: small.modelID })),
|
|
mergeDeep(small.info.options),
|
|
)
|
|
await generateText({
|
|
// use higher # for reasoning models since reasoning tokens eat up a lot of the budget
|
|
maxOutputTokens: small.info.reasoning ? 3000 : 20,
|
|
providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options),
|
|
messages: [
|
|
...SystemPrompt.title(small.providerID).map(
|
|
(x): ModelMessage => ({
|
|
role: "system",
|
|
content: x,
|
|
}),
|
|
),
|
|
{
|
|
role: "user",
|
|
content: "Generate a title for this conversation:\n",
|
|
},
|
|
...MessageV2.toModelMessage([
|
|
{
|
|
info: {
|
|
id: Identifier.ascending("message"),
|
|
role: "user",
|
|
sessionID: input.session.id,
|
|
time: {
|
|
created: Date.now(),
|
|
},
|
|
agent: input.message.info.role === "user" ? input.message.info.agent : "build",
|
|
model: {
|
|
providerID: input.providerID,
|
|
modelID: input.modelID,
|
|
},
|
|
},
|
|
parts: input.message.parts,
|
|
},
|
|
]),
|
|
],
|
|
headers: small.info.headers,
|
|
model: small.language,
|
|
})
|
|
.then((result) => {
|
|
if (result.text)
|
|
return Session.update(input.session.id, (draft) => {
|
|
const cleaned = result.text
|
|
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.find((line) => line.length > 0)
|
|
if (!cleaned) return
|
|
|
|
const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
|
|
draft.title = title
|
|
})
|
|
})
|
|
.catch((error) => {
|
|
log.error("failed to generate title", { error, model: small.info.id })
|
|
})
|
|
}
|
|
}
|