Skip to content
← Public packages

@kentcdodds/ai

Kody tool-using agent turns with Vercel AI SDK and Cloudflare AI Gateway.

src/turn.ts

965 lines · 28.9 KB · TypeScript
import { kody } from 'kody:runtime'
import { readNamedSetting, writeNamedSetting } from './legacy-value.ts'

const defaultMaxSteps = 8
const defaultModel = '@cf/mistralai/mistral-small-3.1-24b-instruct'
const cloudflareApiToken = '{{secret:cloudflareApiToken}}'
const defaultSearchLimit = 15
const gatewayName = 'kody'

export type AgentTurnMessage = {
	role: 'system' | 'user' | 'assistant' | 'tool'
	content?: string | null
	tool_calls?: unknown[]
	tool_call_id?: string
	name?: string
}

export type AgentTurnInput = {
	messages: AgentTurnMessage[]
	system?: string | { content: string; cache?: 'prefix' }
	sessionId?: string
	maxSteps?: number
	modelId?: string
	modelLane?: string
	conversationId?: string
	memoryContext?: {
		task?: string
		query?: string
		entities?: string[]
		constraints?: string[]
	}
}

export type AgentToolTrace = {
	id: string
	toolName: string
	input: unknown
	output?: unknown
	error?: string
}

export type AgentTurnResult = {
	assistantText: string
	reasoningText?: string
	summary?: string | null
	continueRecommended?: boolean
	needsUserInput?: boolean
	stepsUsed?: number
	newInformation?: boolean
	stopReason?: string
	finishReason?: string
	toolCalls?: AgentToolTrace[]
	conversationId?: string
	text?: string
	response?: string
	error?: string
}

export type AgentTurnStreamEvent =
	| { type: 'assistant_delta'; text: string }
	| { type: 'reasoning_delta'; text: string }
	| { type: 'tool_call_started'; id: string; toolName: string; input: unknown }
	| {
			type: 'tool_call_finished'
			id: string
			toolName: string
			input: unknown
			output?: unknown
			error?: string
	  }
	| ({ type: 'turn_complete' } & AgentTurnResult)
	| { type: 'error'; message: string; phase: string }

export type AgentTurnOutput = {
	ok: boolean
	result?: AgentTurnResult
	error?: string
	events?: AgentTurnStreamEvent[]
}


type ModelCallResult = {
	text: string
	reasoningText: string
	finishReason: string
	toolCalls: Array<{ toolCallId: string; toolName: string; input: Record<string, unknown> }>
}

function clean(value: unknown) {
	return String(value ?? '').trim()
}

function stringify(value: unknown) {
	try {
		return JSON.stringify(value, null, 2)
	} catch {
		return String(value)
	}
}

async function readUserValue(name: string) {
	return await readNamedSetting(name)
}

async function resolveAccountId() {
	const accountId = await readUserValue('cloudflareAccountId')
	if (!accountId) throw new Error('cloudflareAccountId is required in this package\'s storage.')
	return accountId
}

async function resolveModelId(input: AgentTurnInput) {
	if (input.modelId) return input.modelId
	return (await readUserValue('cloudflareAiModel').catch(() => '')) || defaultModel
}

function normalizeSystem(system: AgentTurnInput['system']) {
	if (typeof system === 'string') return system
	if (system && typeof system.content === 'string') return system.content
	return ''
}

function normalizeMessages(input: AgentTurnInput) {
	const messages: AgentTurnMessage[] = []
	const system = normalizeSystem(input.system)
	if (system) messages.push({ role: 'system', content: system })
	for (const message of Array.isArray(input.messages) ? input.messages : []) {
		messages.push({ role: message.role || 'user', content: String(message.content ?? '') })
	}
	return messages
}

/** Some models reject `user` immediately after `tool`; bridge with a short assistant turn. */
function pushUserAfterTools(messages: AgentTurnMessage[], content: string) {
	const last = messages[messages.length - 1]
	if (last?.role === 'tool') {
		messages.push({
			role: 'assistant',
			content: 'Tool calls finished. I will continue from those results.',
		})
	}
	messages.push({ role: 'user', content })
}

function parseArguments(value: unknown): Record<string, unknown> {
	if (!value) return {}
	if (typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>
	if (typeof value !== 'string') return {}
	try {
		const parsed = JSON.parse(value)
		return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
			? (parsed as Record<string, unknown>)
			: {}
	} catch {
		return {}
	}
}

function toolDefinitions() {
	return [
		{
			type: 'function',
			function: {
				name: 'search',
				description:
					'Discover Kody capabilities, saved packages, persisted values, integrations, and secret metadata (not secret values). Use a natural-language `query` for ranked matches, or pass `entity` (`"{id}:{type}"` or an array of refs) for exact detail/snippet for one or more hits before you execute.',
				parameters: {
					type: 'object',
					properties: {
						query: {
							type: 'string',
							description:
								'Natural language description of what you need, or an exact package UUID / kody id / hosted package URL.',
						},
						entity: {
							description:
								'Exact entity ref like "name:capability", "uuid:package", "name:integration", "user:name:value", or "name:secret", or an array of 1–10 refs.',
							anyOf: [
								{ type: 'string' },
								{ type: 'array', items: { type: 'string' } },
							],
						},
						limit: {
							type: 'number',
							description: 'Max ranked matches (default 15).',
						},
					},
				},
			},
		},
		{
			type: 'function',
			function: {
				name: 'execute',
				description:
					'Run one complete ESM module in the Kody sandbox. Required shape: `export default async function main(input = {}) { ... }`. Prefer `import { kody } from "kody:runtime"` and `kody:@scope/package[/export]` from search/entity detail. Project/slim large payloads before returning. Pass optional `params` as main\'s first argument.',
				parameters: {
					type: 'object',
					properties: {
						code: {
							type: 'string',
							description: 'Full ESM module string with a default export function.',
						},
						params: {
							type: 'object',
							description: 'Optional JSON passed as the first argument to main.',
						},
					},
					required: ['code'],
				},
			},
		},
	]
}

function choice(payload: unknown) {
	const choices =
		payload && typeof payload === 'object'
			? (payload as { choices?: unknown[] }).choices
			: null
	return Array.isArray(choices) && choices[0] && typeof choices[0] === 'object'
		? (choices[0] as Record<string, unknown>)
		: null
}

function message(payload: unknown) {
	const msg = choice(payload)?.message
	return msg && typeof msg === 'object' ? (msg as Record<string, unknown>) : null
}

function textFrom(payload: unknown) {
	const msg = message(payload)
	if (typeof msg?.content === 'string') return msg.content
	return ''
}

function reasoningFrom(payload: unknown) {
	const msg = message(payload)
	const reasoning = msg?.reasoning ?? msg?.reasoning_content
	return typeof reasoning === 'string' ? reasoning : ''
}

function toolCallsFrom(payload: unknown) {
	const msg = message(payload)
	const calls = msg?.tool_calls
	return Array.isArray(calls) ? (calls as Record<string, unknown>[]) : []
}

function finishReasonFrom(payload: unknown) {
	const reason = choice(payload)?.finish_reason
	return typeof reason === 'string' ? reason : 'stop'
}

function toolName(call: Record<string, unknown>) {
	return typeof call?.name === 'string'
		? call.name
		: typeof (call?.function as { name?: string })?.name === 'string'
			? (call.function as { name: string }).name
			: ''
}

function toolArgs(call: Record<string, unknown>) {
	return parseArguments(call?.arguments ?? (call?.function as { arguments?: unknown })?.arguments)
}

function toolId(call: Record<string, unknown>, index: number) {
	return typeof call?.id === 'string' && call.id ? call.id : 'tool-call-' + index
}

const knownToolNames = new Set(['search', 'execute'])

function stripToolCallPrefixes(text: string) {
	return clean(text)
		.replace(/^```(?:json)?\s*/i, '')
		.replace(/\s*```$/i, '')
		.replace(/^\[?\s*TOOL_CALLS?\s*\]?\s*/i, '')
		.replace(/^TOOL_CALLS?\s*[:=]\s*/i, '')
		.trim()
}

function extractBalancedJsonSlice(text: string, openChar: '[' | '{') {
	const closeChar = openChar === '[' ? ']' : '}'
	const start = text.indexOf(openChar)
	if (start < 0) return null
	let depth = 0
	let inString = false
	let escaped = false
	for (let i = start; i < text.length; i += 1) {
		const ch = text[i]
		if (inString) {
			if (escaped) escaped = false
			else if (ch === '\\') escaped = true
			else if (ch === '"') inString = false
			continue
		}
		if (ch === '"') {
			inString = true
			continue
		}
		if (ch === openChar) depth += 1
		else if (ch === closeChar) {
			depth -= 1
			if (depth === 0) return text.slice(start, i + 1)
		}
	}
	return null
}

function extractJsonCandidates(text: string) {
	const normalized = stripToolCallPrefixes(text)
	const candidates = new Set<string>()
	if (normalized) candidates.add(normalized)
	const arraySlice = extractBalancedJsonSlice(normalized, '[')
	if (arraySlice) candidates.add(arraySlice)
	const objectSlice = extractBalancedJsonSlice(normalized, '{')
	if (objectSlice) candidates.add(objectSlice)
	return [...candidates]
}

function coerceToolCallItems(parsed: unknown): Record<string, unknown>[] | null {
	if (Array.isArray(parsed)) {
		if (parsed.length === 0) return null
		if (!parsed.every((item) => item && typeof item === 'object' && !Array.isArray(item))) return null
		return parsed as Record<string, unknown>[]
	}
	if (parsed && typeof parsed === 'object') {
		const record = parsed as Record<string, unknown>
		if (Array.isArray(record.tool_calls)) return coerceToolCallItems(record.tool_calls)
		if (Array.isArray(record.tools)) return coerceToolCallItems(record.tools)
		return [record]
	}
	return null
}

/**
 * Some models emit tool calls as JSON text (sometimes prefixed with [TOOL_CALLS])
 * instead of structured tool_calls. Recover those so the turn loop executes them.
 */
function parseTextToolCalls(text: string): ModelCallResult['toolCalls'] {
	for (const candidate of extractJsonCandidates(text)) {
		if (!(candidate.startsWith('[') || candidate.startsWith('{'))) continue
		let parsed: unknown
		try {
			parsed = JSON.parse(candidate)
		} catch {
			continue
		}
		const items = coerceToolCallItems(parsed)
		if (!items) continue

		const calls: ModelCallResult['toolCalls'] = []
		let valid = true
		for (let index = 0; index < items.length; index += 1) {
			const record = items[index]
			const name =
				typeof record.name === 'string'
					? record.name
					: typeof record.toolName === 'string'
						? record.toolName
						: typeof (record.function as { name?: string } | undefined)?.name === 'string'
							? (record.function as { name: string }).name
							: ''
			if (!knownToolNames.has(name)) {
				valid = false
				break
			}

			const argsRaw =
				record.arguments ??
				record.input ??
				record.args ??
				(record.function as { arguments?: unknown } | undefined)?.arguments ??
				{}
			const input =
				typeof argsRaw === 'string'
					? parseArguments(argsRaw)
					: argsRaw && typeof argsRaw === 'object' && !Array.isArray(argsRaw)
						? (argsRaw as Record<string, unknown>)
						: {}

			if (name === 'search' && typeof input.query !== 'string') {
				valid = false
				break
			}
			if (name === 'execute' && typeof input.code !== 'string') {
				valid = false
				break
			}

			calls.push({
				toolCallId:
					typeof record.id === 'string' && record.id ? record.id : 'text-tool-call-' + index,
				toolName: name,
				input,
			})
		}
		if (valid && calls.length > 0) return calls
	}
	return []
}

function looksLikeRawToolCallText(text: string) {
	return parseTextToolCalls(text).length > 0
}

function looksLikeIncompleteToolCallText(text: string) {
	const value = clean(text)
	if (!value) return false
	if (looksLikeRawToolCallText(value)) return false
	if (/\[?\s*TOOL_CALLS?\s*\]?/i.test(value)) return true
	if (/"name"\s*:\s*"(search|execute)"/.test(value) && /"(arguments|input|code|query)"/.test(value))
		return true
	return false
}

function looksLikeJunkAssistantText(text: string) {
	const value = clean(text)
	if (!value) return false
	if (looksLikeRawToolCallText(value) || looksLikeIncompleteToolCallText(value)) return true
	if (/typeDefinition|"usage"\s*:\s*"import/.test(value)) return true
	if (/"entityRef"|"matches"\s*:/.test(value) && /[{[]/.test(value)) return true
	if (/^[a-zA-Z]\([^)]*params:/.test(value)) return true
	return false
}

async function cloudflareApi<T>(
	accountId: string,
	path: string,
	init: RequestInit = {},
): Promise<{ ok: boolean; status: number; data: T | null; text: string }> {
	const response = await fetch('https://api.cloudflare.com/client/v4/accounts/' + accountId + path, {
		...init,
		headers: {
			Authorization: 'Bearer ' + cloudflareApiToken,
			'Content-Type': 'application/json',
			...(init.headers || {}),
		},
	})
	const text = await response.text()
	let data: T | null = null
	try {
		data = text ? (JSON.parse(text) as T) : null
	} catch {
		data = null
	}
	return { ok: response.ok, status: response.status, data, text }
}

async function resolveGateway(accountId: string): Promise<string> {
	const existing = await readUserValue('cloudflareAiGatewayId')
	if (existing) return existing

	const list = await cloudflareApi<{
		success?: boolean
		result?: Array<{ id?: string }>
		errors?: Array<{ message?: string }>
	}>(accountId, '/ai-gateway/gateways')

	if (!list.ok) {
		const message =
			list.data?.errors?.map((error) => error.message).filter(Boolean).join('; ') ||
			'HTTP ' + list.status
		throw new Error(
			'Could not list AI Gateways (permission or API error): ' +
				message +
				'. Save cloudflareAiGatewayId in this package\'s storage or grant AI Gateway permissions to cloudflareApiToken.',
		)
	}

	const gateways = Array.isArray(list.data?.result) ? list.data!.result! : []
	const match = gateways.find((gateway) => gateway.id === gatewayName)
	if (match?.id) {
		await writeNamedSetting('cloudflareAiGatewayId', match.id)
		return match.id
	}

	const created = await cloudflareApi<{
		success?: boolean
		result?: { id?: string }
		errors?: Array<{ message?: string }>
	}>(accountId, '/ai-gateway/gateways', {
		method: 'POST',
		body: JSON.stringify({ id: gatewayName, cache_ttl: 0, collect_logs: true }),
	})

	if (created.ok && created.data?.result?.id) {
		const gatewayId = created.data.result.id
		await writeNamedSetting('cloudflareAiGatewayId', gatewayId)
		return gatewayId
	}

	const message =
		created.data?.errors?.map((error) => error.message).filter(Boolean).join('; ') ||
		'HTTP ' + created.status
	throw new Error(
		'Could not create AI Gateway "' +
			gatewayName +
			'": ' +
			message +
			'. Save cloudflareAiGatewayId in this package\'s storage or grant AI Gateway permissions to cloudflareApiToken.',
	)
}

function modelBaseUrl(accountId: string, gatewayId: string) {
	return 'https://gateway.ai.cloudflare.com/v1/' + accountId + '/' + gatewayId + '/workers-ai/v1'
}

function sanitizeMessagesForModel(messages: AgentTurnMessage[]) {
	const out: AgentTurnMessage[] = []
	for (const message of messages) {
		const last = out[out.length - 1]
		if (message.role === 'user' && last?.role === 'tool') {
			out.push({
				role: 'assistant',
				content: 'Tool calls finished. I will continue from those results.',
			})
		}
		const next: AgentTurnMessage = {
			role: message.role,
			content: message.content == null ? '' : message.content,
		}
		if (message.tool_calls) next.tool_calls = message.tool_calls
		if (message.tool_call_id) next.tool_call_id = message.tool_call_id
		if (message.name) next.name = message.name
		out.push(next)
	}
	return out
}

async function callAi({
	modelId,
	messages,
	useTools = true,
	accountId,
	gatewayId,
}: {
	modelId: string
	messages: AgentTurnMessage[]
	useTools?: boolean
	accountId: string
	gatewayId: string
}): Promise<ModelCallResult> {
	const body: Record<string, unknown> = {
		model: modelId,
		messages: sanitizeMessagesForModel(messages),
		max_tokens: 4096,
	}
	if (useTools) {
		body.tools = toolDefinitions()
		body.tool_choice = 'auto'
	}

	async function postChat(baseUrl: string) {
		const response = await fetch(baseUrl + '/chat/completions', {
			method: 'POST',
			headers: {
				Authorization: 'Bearer ' + cloudflareApiToken,
				'Content-Type': 'application/json',
			},
			body: JSON.stringify(body),
		})
		const raw = await response.text()
		let payload: unknown = raw
		try {
			payload = raw ? JSON.parse(raw) : null
		} catch {
			payload = raw
		}
		return { response, payload }
	}

	const { response, payload } = await postChat(modelBaseUrl(accountId, gatewayId))
	if (!response.ok) {
		throw new Error('Workers AI chat request failed: ' + response.status + ' ' + stringify(payload))
	}
	const calls = toolCallsFrom(payload)
	return {
		text: textFrom(payload),
		reasoningText: reasoningFrom(payload),
		finishReason: finishReasonFrom(payload),
		toolCalls: calls.map((call, index) => ({
			toolCallId: toolId(call, index),
			toolName: toolName(call),
			input: toolArgs(call),
		})),
	}
}

/**
 * MCP search/execute wrap payloads as `{ conversationId, timing, result, ... }`.
 * Runtime helpers may already return the inner shape. Normalize both.
 */
function unwrapMcpToolPayload(output: unknown): unknown {
	if (typeof output === 'string') {
		const trimmed = output.trim()
		if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
			try {
				return unwrapMcpToolPayload(JSON.parse(trimmed))
			} catch {
				return output
			}
		}
		return output
	}
	if (!output || typeof output !== 'object' || Array.isArray(output)) return output
	const record = output as Record<string, unknown>
	// Already a flat ranked search payload.
	if (Array.isArray(record.matches)) return output
	// Already an entity-detail payload.
	if (record.kind === 'entity' && (record.entityRef || record.id)) return output

	const envelopeHints =
		'timing' in record || 'returnedBytes' in record || 'logs' in record
	if (!envelopeHints) return output

	// Success envelopes nest the payload under `result`.
	if ('result' in record) {
		const inner = record.result
		if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
			const innerRecord = inner as Record<string, unknown>
			return {
				...innerRecord,
				conversationId: record.conversationId ?? innerRecord.conversationId ?? null,
				error: typeof record.error === 'string' ? record.error : innerRecord.error,
			}
		}
		return {
			result: inner ?? null,
			error: typeof record.error === 'string' ? record.error : null,
			conversationId: record.conversationId ?? null,
		}
	}

	// Failed execute envelopes often have `error` without `result`.
	if ('error' in record) {
		return {
			error: record.error ?? null,
			conversationId: record.conversationId ?? null,
		}
	}
	return output
}

function projectMatch(match: unknown) {
	const item = match && typeof match === 'object' ? (match as Record<string, unknown>) : {}
	return {
		kind: item.kind ?? null,
		type: item.type ?? null,
		id: item.id ?? null,
		entityRef: item.entityRef ?? null,
		title: item.title ?? item.name ?? null,
		description: typeof item.description === 'string' ? item.description.slice(0, 240) : null,
		usage: typeof item.usage === 'string' ? item.usage.slice(0, 280) : null,
		executeExample:
			typeof item.executeExample === 'string' ? item.executeExample.slice(0, 500) : null,
	}
}

/** Compact search payloads for weaker models / host tool loops. */
export function projectSearchOutput(output: unknown) {
	const unwrapped = unwrapMcpToolPayload(output)
	const record =
		unwrapped && typeof unwrapped === 'object' ? (unwrapped as Record<string, unknown>) : {}
	const nested =
		record.result && typeof record.result === 'object' && !Array.isArray(record.result)
			? (record.result as Record<string, unknown>)
			: null
	let matches: unknown[] = Array.isArray(record.matches)
		? record.matches
		: Array.isArray(nested?.matches)
			? nested.matches
			: []
	// Entity detail searches return one object (kind: entity), not a matches array.
	if (
		matches.length === 0 &&
		(record.kind === 'entity' || nested?.kind === 'entity') &&
		(record.entityRef || record.id || nested?.entityRef || nested?.id)
	) {
		matches = [nested?.kind === 'entity' ? nested : record]
	}
	const guidance =
		typeof record.guidance === 'string'
			? record.guidance
			: typeof nested?.guidance === 'string'
				? nested.guidance
				: null
	return {
		conversationId: record.conversationId ?? nested?.conversationId ?? null,
		guidance: guidance ? guidance.slice(0, 800) : null,
		matchCount: matches.length,
		matches: matches.slice(0, 8).map(projectMatch),
	}
}

/** Compact execute payloads for weaker models / host tool loops. */
export function projectExecuteOutput(output: unknown) {
	const unwrapped = unwrapMcpToolPayload(output)
	if (unwrapped == null) return null
	if (typeof unwrapped === 'string') return unwrapped.slice(0, 4000)
	if (typeof unwrapped !== 'object') return unwrapped
	const record = unwrapped as Record<string, unknown>
	const envelopeKeys = new Set(['result', 'conversationId', 'logs', 'returnedBytes', 'timing'])
	const looksLikeExecuteEnvelope =
		'result' in record &&
		!('matches' in record) &&
		record.kind !== 'entity' &&
		!('entityRef' in record) &&
		Object.keys(record).every((key) => envelopeKeys.has(key))
	const value = looksLikeExecuteEnvelope
		? { result: record.result, conversationId: record.conversationId ?? null }
		: unwrapped
	try {
		const serialized = JSON.stringify(value)
		if (serialized.length <= 4000) return value
		return {
			truncated: true,
			preview: serialized.slice(0, 4000),
		}
	} catch {
		return { error: 'Could not serialize execute output.' }
	}
}

async function callTool({
	toolName: name,
	args,
	conversationId,
	memoryContext,
}: {
	toolName: string
	args: Record<string, unknown>
	conversationId: string
	memoryContext?: AgentTurnInput['memoryContext']
}) {
	// Execute Kody tools directly in this package runtime (not via self-MCP).
	// Host packages that need their own secret allowlists should call runModelStep
	// and run search/execute themselves.
	if (name === 'search') {
		const query = clean(args.query)
		const entity = args.entity
		if (!query && entity == null) throw new Error('search requires query or entity.')
		const payload: Record<string, unknown> = {
			limit:
				typeof args.limit === 'number'
					? Math.min(Math.max(Math.floor(args.limit), 1), 50)
					: defaultSearchLimit,
			conversationId,
			memoryContext,
		}
		if (query) payload.query = query
		if (entity != null) payload.entity = entity
		return await kody.search(payload as Parameters<typeof kody.search>[0])
	}
	if (name === 'execute') {
		const code = clean(args.code)
		if (!code) throw new Error('execute requires code.')
		return await kody.execute({
			code,
			params: args.params && typeof args.params === 'object' ? args.params : undefined,
			conversationId,
		})
	}
	throw new Error('Unknown Kody agent tool: ' + name)
}

function inferNeedsUserInput(text: string) {
	const lower = text.toLowerCase()
	return (
		lower.includes('?') &&
		(lower.includes('could you') ||
			lower.includes('can you clarify') ||
			lower.includes('what do you mean') ||
			lower.includes('which part'))
	)
}

function stopReason({
	text,
	finishReason,
	stepsUsed,
	maxSteps,
	toolCalls,
}: {
	text: string
	finishReason: string
	stepsUsed: number
	maxSteps: number
	toolCalls: AgentToolTrace[]
}) {
	if (inferNeedsUserInput(text)) return 'needs_user'
	if (toolCalls.some((call) => call.error)) return 'tool_error'
	if (finishReason === 'tool-calls' || finishReason === 'tool_calls' || stepsUsed >= maxSteps)
		return 'budget_exhausted'
	return 'completed'
}

/**
 * Run one complete Kody tool-using agent turn via Cloudflare AI Gateway. Tools call kody.search/execute directly in this package runtime.
 * @param input.messages - At least one message; uses `cloudflareApiToken`, `cloudflareAccountId`, optional `cloudflareAiModel` and `cloudflareAiGatewayId`.
 * @returns `{ ok, result, events }` with assistant text, tool traces, and buffered event log.
 * @example
 * import runAgentTurn from 'kody:@kentcdodds/ai/turn'
 * const turn = await runAgentTurn({ messages: [{ role: 'user', content: 'Search for Gmail helpers' }] })
 * // => { ok: true, result: { assistantText: '...', toolCalls: [...] }, events: [...] }
 */
export async function runAgentTurn(input: AgentTurnInput = { messages: [] }): Promise<AgentTurnOutput> {
	try {
		if (!Array.isArray(input.messages) || input.messages.length === 0)
			throw new Error('messages must include at least one message.')
		const conversationId = input.conversationId || crypto.randomUUID()
		const accountId = await resolveAccountId()
		const gatewayId = await resolveGateway(accountId)
		const modelId = await resolveModelId(input)
		const maxSteps = Math.max(1, Math.min(Number(input.maxSteps) || defaultMaxSteps, 50))
		const messages = normalizeMessages(input)
		const events: AgentTurnStreamEvent[] = []
		const toolCalls: AgentToolTrace[] = []
		let assistantText = ''
		let reasoningText = ''
		let finishReason = 'stop'
		let stepsUsed = 0

		for (let step = 0; step < maxSteps; step += 1) {
			const result = await callAi({
				modelId,
				messages,
				accountId,
				gatewayId,
			})
			stepsUsed = step + 1
			finishReason = result.finishReason
			const text = result.text
			const reasoning = result.reasoningText
			if (reasoning) reasoningText += reasoning
			const structuredCalls = Array.isArray(result.toolCalls) ? result.toolCalls : []
			const recoveredCalls =
				structuredCalls.length === 0 && text ? parseTextToolCalls(text) : []
			const calls = structuredCalls.length > 0 ? structuredCalls : recoveredCalls
			const recoveredFromText = structuredCalls.length === 0 && recoveredCalls.length > 0

			if (!calls || calls.length === 0) {
				if (looksLikeIncompleteToolCallText(text) || looksLikeJunkAssistantText(text)) {
					messages.push({
						role: 'assistant',
						content: text || null,
					})
					pushUserAfterTools(
						messages,
						'That was not a valid final answer. Use the tool-calling API for search/execute (do not write TOOL_CALLS JSON or API docs in text). Then finish with a short human summary of outcomes only.',
					)
					continue
				}
				if (text) {
					assistantText += text
					events.push({ type: 'assistant_delta', text })
				}
				break
			}

			if (recoveredFromText) {
				events.push({
					type: 'assistant_delta',
					text: '[recovered tool calls from assistant text]\n',
				})
			}

			const assistantToolCalls = calls.map((call) => ({
				id: call.toolCallId,
				type: 'function' as const,
				function: { name: call.toolName, arguments: stringify(call.input) },
			}))
			// When recovering from text, omit the raw JSON content so it is not treated as the answer.
			messages.push({
				role: 'assistant',
				content: recoveredFromText ? null : text || null,
				tool_calls: assistantToolCalls,
			})

			for (const call of calls) {
				const id = call.toolCallId
				const name = call.toolName
				const args = call.input
				const trace: AgentToolTrace = { id, toolName: name, input: args }
				events.push({ type: 'tool_call_started', id, toolName: name, input: args })
				try {
					const output = await callTool({
						toolName: name,
						args,
						conversationId,
						memoryContext: input.memoryContext,
					})
					// Keep tool outputs compact so weaker models do not echo giant search payloads.
					trace.output =
						name === 'search' ? projectSearchOutput(output) : projectExecuteOutput(output)
				} catch (error) {
					trace.error = error instanceof Error ? error.message : String(error)
				}
				toolCalls.push(trace)
				events.push({
					type: 'tool_call_finished',
					id,
					toolName: name,
					input: args,
					output: trace.output,
					error: trace.error,
				})
				messages.push({
					role: 'tool',
					tool_call_id: id,
					name,
					content: stringify(trace.output ?? { error: trace.error ?? null }).slice(0, 8000),
				})
			}
		}

		if (
			(!clean(assistantText) && toolCalls.length > 0) ||
			looksLikeRawToolCallText(assistantText) ||
			looksLikeJunkAssistantText(assistantText)
		) {
			pushUserAfterTools(
				messages,
				'Provide the final concise human answer now using the prior tool results. Do not call tools, do not emit TOOL_CALLS/JSON, and do not paste API docs. If blocked, summarize the specific blocker and what was tried.',
			)
			const finalResult = await callAi({
				modelId,
				messages,
				useTools: false,
				accountId,
				gatewayId,
			})
			const finalText = finalResult.text
			const finalReasoning = finalResult.reasoningText
			finishReason = finalResult.finishReason
			if (finalReasoning) reasoningText += finalReasoning
			if (finalText && !looksLikeJunkAssistantText(finalText)) {
				assistantText = finalText
				events.push({ type: 'assistant_delta', text: finalText })
			} else if (looksLikeJunkAssistantText(assistantText)) {
				assistantText = ''
			}
		}

		const text = clean(assistantText)
		const turnResult: AgentTurnResult = {
			assistantText: text,
			reasoningText: clean(reasoningText),
			summary: null,
			continueRecommended: false,
			needsUserInput: inferNeedsUserInput(text),
			stepsUsed,
			newInformation: true,
			stopReason: stopReason({ text, finishReason, stepsUsed, maxSteps, toolCalls }),
			finishReason,
			toolCalls,
			conversationId,
		}
		events.push({ type: 'turn_complete', ...turnResult })

		return { ok: true, result: turnResult, events }
	} catch (error) {
		return { ok: false, error: error instanceof Error ? error.message : String(error) }
	}
}

export default runAgentTurn