Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 98 additions & 136 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,8 +499,25 @@ export namespace MessageV2 {
model: Provider.Model,
options?: { stripMedia?: boolean },
): ModelMessage[] {
// Solution A: the agent prompts (see session/prompt/*) now explicitly
// tell the model to start each `<tool_call>` on a fresh line. This helps
// avoid the problem in the first place.
// Solution B: some tool callers still forget and stick the tag immediately
// after text. the underlying parser in the AI SDK only recognizes a tool
// call if it begins on a new line, so we sanitize the text by inserting a
// newline whenever a tool call tag appears immediately after non-newline
// content.
const msgs = input.map((m) => ({ ...m, parts: [...m.parts] }))
for (const msg of msgs) {
for (const part of msg.parts) {
if (part.type === "text" && typeof part.text === "string") {
part.text = part.text.replace(/([^\n])(<tool_call>)/g, "$1\n$2")
}
}
}

const result: UIMessage[] = []
const toolNames = new Set<string>()
const names = new Set<string>()
// Track media from tool results that need to be injected as user messages
// for providers that don't support media in tool results.
//
Expand Down Expand Up @@ -555,178 +572,123 @@ export namespace MessageV2 {
return { type: "json", value: output as never }
}

for (const msg of input) {
for (const msg of msgs) {
if (msg.parts.length === 0) continue

if (msg.info.role === "user") {
const userMessage: UIMessage = {
id: msg.info.id,
role: "user",
parts: [],
}
result.push(userMessage)
for (const part of msg.parts) {
if (part.type === "text" && !part.ignored)
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") {
if (options?.stripMedia && isMedia(part.mime)) {
userMessage.parts.push({
type: "text",
text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`,
})
} else {
userMessage.parts.push({
type: "file",
url: part.url,
mediaType: part.mime,
filename: part.filename,
})
let content: string | Array<{ type: string; [key: string]: unknown }> | undefined
for (const p of msg.parts) {
if (p.type === "text" && !p.ignored) {
content = content === undefined ? p.text : (typeof content === "string" ? content + p.text : [...content, { type: "text", text: p.text }])
}
if (p.type === "file" && p.mime !== "text/plain" && p.mime !== "application/x-directory") {
const txt = options?.stripMedia && isMedia(p.mime) ? `[Attached ${p.mime}: ${p.filename ?? "file"}]` : undefined
const file = txt ? undefined : { type: "file" as const, url: p.url, mediaType: p.mime, filename: p.filename }
if (txt) {
content = content === undefined ? txt : (typeof content === "string" ? content + txt : [...content, { type: "text", text: txt }])
}
if (file) {
content = content === undefined ? [file] : (typeof content === "string" ? [{ type: "text", text: content }, file] : [...content, file])
}
}

if (part.type === "compaction") {
userMessage.parts.push({
type: "text",
text: "What did we do so far?",
})
if (p.type === "compaction") {
const txt = "What did we do so far?"
content = content === undefined ? txt : (typeof content === "string" ? content + txt : [...content, { type: "text", text: txt }])
}
if (part.type === "subtask") {
userMessage.parts.push({
type: "text",
text: "The following tool was executed by the user",
})
if (p.type === "subtask") {
const txt = "The following tool was executed by the user"
content = content === undefined ? txt : (typeof content === "string" ? content + txt : [...content, { type: "text", text: txt }])
}
}
if (content !== undefined) {
result.push({ id: msg.info.id, role: "user", content })
}
}

if (msg.info.role === "assistant") {
const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
const diff = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
const media: Array<{ mime: string; url: string }> = []

if (
msg.info.error &&
!(
MessageV2.AbortedError.isInstance(msg.info.error) &&
msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
)
) {
if (msg.info.error && !(MessageV2.AbortedError.isInstance(msg.info.error) && msg.parts.some((p) => p.type !== "step-start" && p.type !== "reasoning"))) {
continue
}
const assistantMessage: UIMessage = {
id: msg.info.id,
role: "assistant",
parts: [],
}
for (const part of msg.parts) {
if (part.type === "text")
assistantMessage.parts.push({
type: "text",
text: part.text,
...(differentModel ? {} : { providerMetadata: part.metadata }),
})
if (part.type === "step-start")
assistantMessage.parts.push({
type: "step-start",
})
if (part.type === "tool") {
toolNames.add(part.tool)
if (part.state.status === "completed") {
const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])

// For providers that don't support media in tool results, extract media files
// (images, PDFs) to be sent as a separate user message
const mediaAttachments = attachments.filter((a) => isMedia(a.mime))
const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime))
if (!supportsMediaInToolResults && mediaAttachments.length > 0) {
media.push(...mediaAttachments)

const msg_out: UIMessage = { id: msg.info.id, role: "assistant", content: [] }
for (const p of msg.parts) {
if (p.type === "text") {
msg_out.content.push({ type: "text", text: p.text, ...(diff ? {} : { providerMetadata: p.metadata }) })
}
if (p.type === "step-start") {
msg_out.content.push({ type: "step-start" })
}
if (p.type === "tool") {
names.add(p.tool)
if (p.state.status === "completed") {
const txt = p.state.time.compacted ? "[Old tool result content cleared]" : p.state.output
const attach = p.state.time.compacted || options?.stripMedia ? [] : (p.state.attachments ?? [])
const media_attach = attach.filter((a) => isMedia(a.mime))
const no_media = attach.filter((a) => !isMedia(a.mime))
if (!supportsMediaInToolResults && media_attach.length > 0) {
media.push(...media_attach)
}
const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments

const output =
finalAttachments.length > 0
? {
text: outputText,
attachments: finalAttachments,
}
: outputText

assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
const final = supportsMediaInToolResults ? attach : no_media
const out = final.length > 0 ? { text: txt, attachments: final } : txt
msg_out.content.push({
type: (`tool-${p.tool}`) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
output,
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
toolCallId: p.callID,
input: p.state.input,
output: out,
...(diff ? {} : { callProviderMetadata: p.metadata }),
})
}
if (part.state.status === "error")
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
if (p.state.status === "error") {
msg_out.content.push({
type: (`tool-${p.tool}`) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: part.state.error,
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
toolCallId: p.callID,
input: p.state.input,
errorText: p.state.error,
...(diff ? {} : { callProviderMetadata: p.metadata }),
})
// Handle pending/running tool calls to prevent dangling tool_use blocks
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
if (part.state.status === "pending" || part.state.status === "running")
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
}
if (p.state.status === "pending" || p.state.status === "running") {
msg_out.content.push({
type: (`tool-${p.tool}`) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
toolCallId: p.callID,
input: p.state.input,
errorText: "[Tool execution was interrupted]",
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
...(diff ? {} : { callProviderMetadata: p.metadata }),
})
}
}
if (part.type === "reasoning") {
assistantMessage.parts.push({
type: "reasoning",
text: part.text,
...(differentModel ? {} : { providerMetadata: part.metadata }),
})
if (p.type === "reasoning") {
msg_out.content.push({ type: "reasoning", text: p.text, ...(diff ? {} : { providerMetadata: p.metadata }) })
}
}
if (assistantMessage.parts.length > 0) {
result.push(assistantMessage)
// Inject pending media as a user message for providers that don't support
// media (images, PDFs) in tool results
if (Array.isArray(msg_out.content) && msg_out.content.length > 0) {
result.push(msg_out)
if (media.length > 0) {
result.push({
id: MessageID.ascending(),
role: "user",
parts: [
{
type: "text" as const,
text: "Attached image(s) from tool result:",
},
...media.map((attachment) => ({
type: "file" as const,
url: attachment.url,
mediaType: attachment.mime,
})),
],
content: [{ type: "text", text: "Attached image(s) from tool result:" }, ...media.map((a) => ({ type: "file" as const, url: a.url, mediaType: a.mime }))],
})
}
}
}
}

const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }]))

return convertToModelMessages(
result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")),
{
//@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput)
tools,
},
)
const tools = Object.fromEntries(Array.from(names).map((name) => [name, { toModelOutput }]))
const isValid = (msg: UIMessage) => {
const c = msg.content
return c !== undefined && (typeof c === "string" ? c.length > 0 : Array.isArray(c) && c.length > 0 && c.some((p) => p.type !== "step-start"))
}
return convertToModelMessages(result.filter(isValid), {
//@ts-expect-error
tools,
})
}

export const stream = fn(SessionID.zod, async function* (sessionID) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ assistant: src/foo.c
When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
- **Tool call formatting:** When initiating a tool call you should always place it on its own line with a newline (or blank line) before the `<tool_call>` tag. This ensures the markup is recognized even if there is preceding text, and helps avoid situations where a function call is ignored.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface.
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/prompt/anthropic.txt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ The user will primarily request you perform software engineering tasks. This inc

- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
- **Tool call formatting:** When initiating a tool call you should always place it on its own line with a newline before the `<tool_call>` tag. This ensures the markup is recognized even if there is preceding text.
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls.
- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly.
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/prompt/codex_header.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ You are an interactive CLI tool that helps users with software engineering tasks

## Tool usage
- Prefer specialized tools over shell for file operations:
- When issuing a tool call, always start it on its own line by inserting a newline before the `<tool_call>` tag. This helps ensure the call is recognized even if text appears just before it.
- Use Read to view files, Edit to modify files, and Write only when needed.
- Use Glob to find files by name and Grep to search file contents.
- Use Bash for terminal operations (git, bun, builds, tests, running scripts).
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/prompt/gemini.txt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring,
- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer.
- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace.
- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself.
- **Tool call formatting:** Always start a tool call on a fresh line by inserting a newline (or blank line) before `<tool_call>`. This helps the system detect and execute your tool calls correctly even when text appears immediately before them.
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate.

## Security and Safety Rules
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/prompt/qwen.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ When the user directly asks about opencode (eg 'can opencode do...', 'does openc
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
- When issuing a tool call, always start it on a new line by inserting a newline before the `<tool_call>` tag. This ensures the call is properly recognized even if preceding text appears on the same line.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
Expand Down
Loading
Loading