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)