Skip to content
← Public packages

@kentcdodds/ai

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

src/index.ts

113 lines · 5.6 KB · TypeScript
import { runAgentTurn } from './turn.ts'
import type {
	AgentChatTurnOutput,
	AgentTurnInput,
	AgentTurnResult,
	AgentTurnStreamEvent,
} from './types.ts'

export { runModelStep, kodyAgentToolDefinitions } from './model-step.ts'
export { projectSearchOutput, projectExecuteOutput } from './turn.ts'

/** Default system guidance for any Kody agent turn (email, chat, automation). */
export const defaultKodyAgentSystem = [
	'You are Kody AI: a concise tool-using assistant with two tools, `search` and `execute`.',
	'Workflow (generic — apply to any task):',
	'1. Prefer `search` first when you need to discover capabilities, saved packages, values, integrations, or secret metadata. Skip search for trivial pure-JS work (dates, formatting, simple transforms) — go straight to `execute`.',
	"2. When a search hit looks useful but usage is unclear, call `search` again with `entity` set to that hit's `entityRef` (examples: `integrationList:capability`, `discord:package`, `user:someValue:value`) before inventing APIs.",
	'3. When entity detail includes `executeExample`, prefer that module (or a slim projection of its return value) instead of inventing imports. Always `import { kody } from "kody:runtime"` before calling `kody.*`.',
	'4. When the plan is clear, call `execute` with one complete ESM module that has `export default async function main(input = {}) { ... }` and returns a slim projected result. Prefer meaningful work per execute over tiny probe loops. On execute errors, read the error and change approach — do not retry the same failing import/code.',
	'Search result types matter: `package` = saved package code; `capability` = builtin/runtime API; `value` = persisted config; `integration` / `secret` = auth metadata. Do not call values or channel ids "packages".',
	'Inside execute: import helpers from `kody:runtime` (`import { kody } from "kody:runtime"`) and saved packages as `kody:@scope/package` / `kody:@scope/package/export` exactly as search/entity detail documents. Prefer those over raw third-party SDKs.',
	'Auth: never ask the user to paste secrets. Use saved integrations/secrets via documented Kody helpers, or report the specific blocker with evidence.',
	'Tool calls: use the tool-calling API only. Never write TOOL_CALLS, tool JSON, or API docs as your final answer text.',
	'Answers: be brief, grounded in tool evidence, and summarize outcomes (or the blocker) — not raw dumps.',
].join('\n')

function normalizeInput(input: AgentTurnInput = { messages: [] }): AgentTurnInput {
	const messages = Array.isArray(input.messages) ? input.messages : []
	const system = input.system ?? defaultKodyAgentSystem
	return { ...input, messages, system }
}

function toResult(
	output: { result?: AgentTurnResult; ok?: boolean; error?: string } | null | undefined,
	input: AgentTurnInput,
): AgentTurnResult {
	const result = output?.result ?? (output as AgentTurnResult | undefined)
	const assistantText =
		result?.assistantText ??
		result?.text ??
		result?.response ??
		result?.error ??
		(typeof result === 'string' ? result : JSON.stringify(result ?? {}))
	return {
		assistantText: String(assistantText),
		reasoningText: result?.reasoningText ?? '',
		summary: result?.summary ?? null,
		continueRecommended: Boolean(result?.continueRecommended),
		needsUserInput: Boolean(result?.needsUserInput),
		stepsUsed: Number(result?.stepsUsed ?? 1),
		newInformation: result?.newInformation !== false,
		stopReason: result?.stopReason ?? 'completed',
		finishReason: result?.finishReason ?? 'stop',
		toolCalls: result?.toolCalls ?? [],
		conversationId: result?.conversationId ?? input.conversationId ?? crypto.randomUUID(),
	}
}

async function runAgentTurnInternal(
	input: AgentTurnInput = { messages: [] },
): Promise<AgentChatTurnOutput> {
	const normalized = normalizeInput(input)
	const output = await runAgentTurn(normalized)
	return {
		ok: output?.ok !== false,
		error: output?.error,
		result: toResult(output, normalized),
		events: (output?.events ?? []) as AgentTurnStreamEvent[],
	}
}

/**
 * Run one tool-using agent turn and return the buffered result and event log.
 * @param input.messages - At least one user/assistant message; optional `system`, `maxSteps`, `conversationId`.
 * @returns `{ ok, result, events }` with assistant text, tool traces, and buffered stream events.
 * @example
 * import agentChatTurn from 'kody:@kentcdodds/ai'
 * const turn = await agentChatTurn({ messages: [{ role: 'user', content: 'What is the weather?' }] })
 * // => { ok: true, result: { assistantText: '...', toolCalls: [...] }, events: [...] }
 */
export default async function agentChatTurn(
	input: AgentTurnInput = { messages: [] },
): Promise<AgentChatTurnOutput> {
	return await runAgentTurnInternal(input)
}

/**
 * Async generator that yields buffered turn events after the full turn completes.
 * @example
 * import { agentTurnStream } from 'kody:@kentcdodds/ai'
 * for await (const event of agentTurnStream({ messages: [{ role: 'user', content: 'Hi' }] })) {
 *   if (event.type === 'assistant_delta') process.stdout.write(event.text)
 *   if (event.type === 'turn_complete') console.log(event.assistantText)
 * }
 */
export async function* agentTurnStream(
	input: AgentTurnInput = { messages: [] },
): AsyncGenerator<AgentTurnStreamEvent> {
	try {
		const output = await runAgentTurnInternal(input)
		const text = output.result?.assistantText ?? ''
		if (text) yield { type: 'assistant_delta', text }
		if (output.result) yield { type: 'turn_complete', ...output.result }
	} catch (error) {
		yield {
			type: 'error',
			message: error instanceof Error ? error.message : String(error),
			phase: 'run',
		}
	}
}

export { startAgentTurn, readNextAgentTurnEvents, cancelAgentTurn } from './runs.ts'