Skip to content

needsApproval tools not returning a tool_result block #10980

@caelinsutch

Description

@caelinsutch

Description

Running into an issue with missing tool_result blocks after approving / denying a tool use with approval.

Error: undefined: The model returned the following errors: messages.2: tool_use ids were found without tool_result blocks immediately after: tooluse_HgfwsaZXS-qWznevI6y_kw. Each tool_use block must have a corresponding tool_result block in the next message.

Client code for approval:

// Within handleApprove

			await addToolApprovalResponse({
				id: approvalId,
				approved: true,
				reason: `Applied ${input.operations.length} operation(s): ${input.reasoning}`,
			});

// JSX

	// Handle tool invocations for update_current_theme_tokens
			if (part.type === "tool-update_current_theme_tokens") {
				return (
					<div key={`${message.id}-tool-${index}`} className="mt-2 space-y-1">
						{part.input?.operations?.map((op, opIndex) => (
							<TokenChangePreview key={opIndex} operation={op} />
						))}
						{part.state === "approval-requested" && (
							<div className="flex gap-1.5 mt-1.5">
								<button
									type="button"
									onClick={() => handleApprove(part.approval.id, part.input)}
									className="flex items-center gap-1 px-2 py-1 text-[9px] font-medium bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
								>
									<Check className="h-2.5 w-2.5" />
									Apply
								</button>
								<button
									type="button"
									onClick={() => handleDeny(part.approval.id)}
									className="flex items-center gap-1 px-2 py-1 text-[9px] font-medium bg-[#e5e5e5] text-[#666] rounded hover:bg-[#d5d5d5] transition-colors"
								>
									<XIcon className="h-2.5 w-2.5" />
									Reject
								</button>
							</div>
						)}
						{part.state === "output-available" && (
							<div className="text-[9px] text-green-600">✓ Applied</div>
						)}
						{part.state === "output-denied" && (
							<div className="text-[9px] text-red-600">✗ Rejected</div>
						)}
					</div>
				);
			}

Server code:

		const result = streamText({
			model: bedrock("global.anthropic.claude-opus-4-5-20251101-v1:0"),
			system: systemPrompt,
			messages: convertToModelMessages(processedMessages),
			tools: {
				update_current_theme_tokens: updateCurrentThemeTokensTool,
			},
			// Allow up to 3 tool execution steps
			stopWhen: stepCountIs(3),
		});

		return result.toUIMessageStreamResponse({
			originalMessages: processedMessages,
		});

Right now my workaround is pre-processing the messages on the server:

/**
 * Process messages to handle tool approval responses.
 * When a tool has needsApproval: true, the client sends back an approval response
 * but we need to inject the actual tool output for the model to continue.
 */
function processToolApprovalResponses(
	messages: ThemeStudioUIMessage[],
): ThemeStudioUIMessage[] {
	// Deep clone messages to avoid mutation issues
	const clonedMessages = JSON.parse(
		JSON.stringify(messages),
	) as ThemeStudioUIMessage[];

	// Process all messages to ensure all approval responses have outputs
	for (const message of clonedMessages) {
		if (message.role !== "assistant" || !message.parts) {
			continue;
		}

		for (let i = 0; i < message.parts.length; i++) {
			const part = message.parts[i];

			// Only process tool UI parts (they have a type starting with "tool-")
			if (
				!part ||
				typeof part.type !== "string" ||
				!part.type.startsWith("tool-")
			) {
				continue;
			}

			// Cast to access tool-specific properties
			const toolPart = part as {
				type: string;
				state?: string;
				approval?: { id: string; approved?: boolean };
				output?: unknown;
				input?: { operations?: unknown[]; reasoning?: string };
			};

			// Check if this is an approval-responded state without an output
			const hasApprovalResponse =
				toolPart.state === "approval-responded" ||
				(toolPart.approval && "approved" in toolPart.approval);

			if (!hasApprovalResponse) {
				continue;
			}

			// If it already has an output, don't overwrite it
			if (toolPart.output !== undefined) {
				continue;
			}

			// Generate the tool output based on approval status
			const wasApproved = toolPart.approval?.approved === true;
			const operationCount = toolPart.input?.operations?.length ?? 0;

			if (wasApproved) {
				// Tool was approved - provide success output
				toolPart.state = "output-available";
				toolPart.output = {
					success: true,
					message: `Applied ${operationCount} token operation(s)${toolPart.input?.reasoning ? `: ${toolPart.input.reasoning}` : ""}`,
				};
			} else {
				// Tool was denied - provide error output
				toolPart.state = "output-available";
				toolPart.output = {
					success: false,
					message: "User denied the token changes",
				};
			}
		}
	}

	return clonedMessages;
}

Which doesn't seem like the right approach

AI SDK Version

  • ai: beta

Code of Conduct

  • I agree to follow this project's Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai/corecore functions like generateText, streamText, etc. Provider utils, and provider spec.ai/providerrelated to a provider package. Must be assigned together with at least one `provider/*` labelprovider/amazon-bedrockIssues related to the @ai-sdk/amazon-bedrock providerprovider/anthropicIssues related to the @ai-sdk/anthropic providersupport

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions