diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 34596e62902..c2ee8402437 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -274,13 +274,13 @@ export namespace SessionPrompt {
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
let lastUser: MessageV2.User | undefined
- let lastAssistant: MessageV2.Assistant | undefined
+ let lastAssistant: MessageV2.WithParts | 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 (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
lastFinished = msg.info as MessageV2.Assistant
if (lastUser && lastFinished) break
@@ -292,12 +292,33 @@ export namespace SessionPrompt {
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
+ lastAssistant?.info.role === "assistant" &&
+ lastAssistant.info.finish &&
+ !["tool-calls", "unknown"].includes(lastAssistant.info.finish) &&
+ lastUser.id < lastAssistant.info.id
) {
- log.info("exiting loop", { sessionID })
- break
+ // Only exit if the last assistant message actually has text content or no tool calls
+ const hasText = lastAssistant.parts.some((p) => p.type === "text" && !p.synthetic)
+ const hasToolCalls = lastAssistant.parts.some((p) => p.type === "tool")
+
+ if (hasText || !hasToolCalls) {
+ log.info("exiting loop", {
+ sessionID,
+ finish: lastAssistant.info.finish,
+ lastUserID: lastUser.id,
+ lastAssistantID: lastAssistant.info.id,
+ hasText,
+ hasToolCalls,
+ })
+ break
+ }
+
+ log.info("continuing loop despite finish reason", {
+ sessionID,
+ finish: lastAssistant.info.finish,
+ hasText,
+ hasToolCalls,
+ })
}
step++
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index f3a1b04d431..0fd710c803e 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -72,10 +72,20 @@ export const BashTool = Tool.define("bash", async () => {
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
- ),
+ )
+ .optional(),
}),
+ formatValidationError(error) {
+ if (error instanceof z.ZodError) {
+ return `Invalid parameters for tool 'bash':\n${(error as any).errors
+ .map((e: z.ZodIssue) => `- ${e.path.join(".")}: ${e.message}`)
+ .join("\n")}\n\nMake sure to provide 'command' and a concise 'description' of what the command does.`
+ }
+ return `Invalid parameters for tool 'bash': ${error}`
+ },
async execute(params, ctx) {
const cwd = params.workdir || Instance.directory
+ const description = params.description || params.command
if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
@@ -170,7 +180,7 @@ export const BashTool = Tool.define("bash", async () => {
ctx.metadata({
metadata: {
output: "",
- description: params.description,
+ description,
},
})
@@ -180,7 +190,7 @@ export const BashTool = Tool.define("bash", async () => {
metadata: {
// truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
- description: params.description,
+ description,
},
})
}
@@ -244,14 +254,16 @@ export const BashTool = Tool.define("bash", async () => {
output += "\n\n\n" + resultMetadata.join("\n") + "\n"
}
+ const finalOutput = output.trim() || "(Command executed successfully with no output)"
+
return {
- title: params.description,
+ title: description,
metadata: {
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
exit: proc.exitCode,
- description: params.description,
+ description,
},
- output,
+ output: finalOutput,
}
},
}
diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts
index cc3d750078f..c98b9f1e9f8 100644
--- a/packages/opencode/src/tool/ls.ts
+++ b/packages/opencode/src/tool/ls.ts
@@ -107,7 +107,8 @@ export const ListTool = Tool.define("list", {
return output
}
- const output = `${searchPath}/\n` + renderDir(".", 0)
+ const output =
+ files.length > 0 ? `${searchPath}/\n` + renderDir(".", 0) : `(No files found in directory: ${searchPath})`
return {
title: path.relative(Instance.worktree, searchPath),
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index 170d4448088..cedf82c9bd2 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -13,7 +13,7 @@ import { Config } from "../config/config"
import { PermissionNext } from "@/permission/next"
const parameters = z.object({
- description: z.string().describe("A short (3-5 words) description of the task"),
+ description: z.string().describe("A short (3-5 words) description of the task").optional(),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
session_id: z.string().describe("Existing Task session to continue").optional(),
@@ -38,8 +38,17 @@ export const TaskTool = Tool.define("task", async (ctx) => {
return {
description,
parameters,
+ formatValidationError(error) {
+ if (error instanceof z.ZodError) {
+ return `Invalid parameters for tool 'task':\n${(error as any).errors
+ .map((e: z.ZodIssue) => `- ${e.path.join(".")}: ${e.message}`)
+ .join("\n")}\n\nMake sure to provide 'prompt', 'subagent_type' and a concise 'description'.`
+ }
+ return `Invalid parameters for tool 'task': ${error}`
+ },
async execute(params: z.infer, ctx) {
const config = await Config.get()
+ const taskDescription = params.description || "Task"
// Skip permission check when user explicitly invoked via @ or command subtask
if (!ctx.extra?.bypassAgentCheck) {
@@ -48,7 +57,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
patterns: [params.subagent_type],
always: ["*"],
metadata: {
- description: params.description,
+ description: taskDescription,
subagent_type: params.subagent_type,
},
})
@@ -67,7 +76,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
return await Session.create({
parentID: ctx.sessionID,
- title: params.description + ` (@${agent.name} subagent)`,
+ title: taskDescription + ` (@${agent.name} subagent)`,
permission: [
{
permission: "todowrite",
@@ -100,7 +109,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
ctx.metadata({
- title: params.description,
+ title: taskDescription,
metadata: {
sessionId: session.id,
},
@@ -122,7 +131,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
},
}
ctx.metadata({
- title: params.description,
+ title: taskDescription,
metadata: {
summary: Object.values(parts).sort((a, b) => a.id.localeCompare(b.id)),
sessionId: session.id,
@@ -176,7 +185,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const output = text + "\n\n" + ["", `session_id: ${session.id}`, ""].join("\n")
return {
- title: params.description,
+ title: taskDescription,
metadata: {
summary,
sessionId: session.id,
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index d621a6e26bf..7406f2dc86e 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -21,6 +21,14 @@ export const WriteTool = Tool.define("write", {
content: z.string().describe("The content to write to the file"),
filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
}),
+ formatValidationError(error) {
+ if (error instanceof z.ZodError) {
+ return `Invalid parameters for tool 'write':\n${(error as any).errors
+ .map((e: z.ZodIssue) => `- ${e.path.join(".")}: ${e.message}`)
+ .join("\n")}\n\nMake sure to provide 'content' and 'filePath'.`
+ }
+ return `Invalid parameters for tool 'write': ${error}`
+ },
async execute(params, ctx) {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filepath)