Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/ai

src/turn.ts

531 lines · 15.3 KB · TypeScript
import { kody } from 'kody:runtime'
import { generateText, type ChatMessage, type ToolCall } from './complete.ts'
import { kodyAgentToolDefinitions } from './model-step.ts'
import { clean, stringify } from './validation.ts'

const defaultMaxSteps = 8
const defaultSearchLimit = 15

export type AgentTurnMessage = ChatMessage
export type AgentTurnInput = {
	messages: Array<AgentTurnMessage>
	system?: string
	sessionId?: string
	maxSteps?: number
	model?: string
	modelId?: string
	provider?: string
	conversationId?: string
	memoryContext?: {
		task?: string
		query?: string
		entities?: string[]
		constraints?: string[]
	}
	dryRun?: boolean
}

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?: Array<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?: Array<AgentTurnStreamEvent>
	preview?: unknown
}

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

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 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 coerceToolCallItems(parsed: unknown): Array<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 Array<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
}

function parseTextToolCalls(text: string): Array<ToolCall> {
	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)
	for (const candidate of candidates) {
		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: Array<ToolCall> = []
		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
						: ''
			if (!knownToolNames.has(name)) {
				valid = false
				break
			}
			const argsRaw = record.arguments ?? record.input ?? record.args ?? {}
			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 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>
	if (Array.isArray(record.matches)) return output
	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
	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,
		}
	}
	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
			: []
	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']
}) {
	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'))
	)
}

/**
 * Run one tool-using agent turn via the configured provider.
 * Tools call kody.search/execute directly in this package runtime.
 *
 * @example
 * import runAgentTurn from 'kody:@kody/ai/turn'
 * const preview = await runAgentTurn({
 *   messages: [{ role: 'user', content: 'Search for weather helpers' }],
 *   dryRun: true,
 * })
 */
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.')
		}
		if (input.dryRun === true) {
			const preview = await generateText({
				...input,
				system: typeof input.system === 'string' ? input.system : undefined,
				tools: kodyAgentToolDefinitions(),
				dryRun: true,
			})
			return {
				ok: true,
				result: { assistantText: '', toolCalls: [] },
				events: [],
				preview,
			}
		}
		const conversationId = input.conversationId || crypto.randomUUID()
		const maxSteps = Math.max(1, Math.min(Number(input.maxSteps) || defaultMaxSteps, 50))
		const messages: Array<ChatMessage> = []
		if (input.system) messages.push({ role: 'system', content: input.system })
		for (const message of input.messages) {
			messages.push({
				role: message.role || 'user',
				content: String(message.content ?? ''),
			})
		}
		const events: Array<AgentTurnStreamEvent> = []
		const toolCalls: Array<AgentToolTrace> = []
		let assistantText = ''
		let reasoningText = ''
		let finishReason = 'stop'
		let stepsUsed = 0

		for (let step = 0; step < maxSteps; step += 1) {
			const result = await generateText({
				...input,
				messages,
				tools: kodyAgentToolDefinitions(),
			})
			if ('dryRun' in result) {
				return { ok: true, result: { assistantText: '', conversationId }, events: [] }
			}
			if (!result.ok) throw new Error(result.error || 'Model step failed.')
			stepsUsed = step + 1
			finishReason = result.finishReason
			const structuredCalls = result.toolCalls
			const recoveredCalls =
				structuredCalls.length === 0 && result.text ? parseTextToolCalls(result.text) : []
			const calls = structuredCalls.length > 0 ? structuredCalls : recoveredCalls
			if (!calls || calls.length === 0) {
				if (result.text) {
					assistantText += result.text
					events.push({ type: 'assistant_delta', text: result.text })
				}
				if (result.reasoningText) reasoningText += result.reasoningText
				break
			}
			messages.push({
				role: 'assistant',
				content: recoveredCalls.length > 0 ? null : result.text || null,
				tool_calls: calls.map((call) => ({
					id: call.toolCallId,
					type: 'function',
					function: { name: call.toolName, arguments: stringify(call.input) },
				})),
			})
			for (const call of calls) {
				const trace: AgentToolTrace = {
					id: call.toolCallId,
					toolName: call.toolName,
					input: call.input,
				}
				events.push({
					type: 'tool_call_started',
					id: call.toolCallId,
					toolName: call.toolName,
					input: call.input,
				})
				try {
					const output = await callTool({
						toolName: call.toolName,
						args: call.input,
						conversationId,
						memoryContext: input.memoryContext,
					})
					trace.output =
						call.toolName === '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: call.toolCallId,
					toolName: call.toolName,
					input: call.input,
					output: trace.output,
					error: trace.error,
				})
				messages.push({
					role: 'tool',
					tool_call_id: call.toolCallId,
					name: call.toolName,
					content: stringify(trace.output ?? { error: trace.error ?? null }).slice(0, 8000),
				})
			}
		}

		const text = clean(assistantText)
		const turnResult: AgentTurnResult = {
			assistantText: text,
			reasoningText: clean(reasoningText),
			summary: null,
			continueRecommended: false,
			needsUserInput: inferNeedsUserInput(text),
			stepsUsed,
			newInformation: true,
			stopReason: inferNeedsUserInput(text)
				? 'needs_user'
				: toolCalls.some((call) => call.error)
					? 'tool_error'
					: finishReason === 'tool_calls' || stepsUsed >= maxSteps
						? 'budget_exhausted'
						: 'completed',
			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