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 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 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 preview?: unknown } const knownToolNames = new Set(['search', 'execute']) function parseArguments(value: unknown): Record { if (!value) return {} if (typeof value === 'object' && !Array.isArray(value)) { return value as Record } if (typeof value !== 'string') return {} try { const parsed = JSON.parse(value) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {} } 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> | 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> } if (parsed && typeof parsed === 'object') { const record = parsed as Record 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 { const normalized = stripToolCallPrefixes(text) const candidates = new Set() 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 = [] 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) : {} 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 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 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) : {} 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) : {} const nested = record.result && typeof record.result === 'object' && !Array.isArray(record.result) ? (record.result as Record) : 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 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 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 = { 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[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 { 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 = [] 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 = [] const toolCalls: Array = [] 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