← 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 · TypeScriptimport { 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