diff --git a/src/client.ts b/src/client.ts index a028f337..a1559823 100644 --- a/src/client.ts +++ b/src/client.ts @@ -9,7 +9,7 @@ import { } from "./modules/connectors.js"; import { getAccessToken } from "./utils/auth-utils.js"; import { createFunctionsModule } from "./modules/functions.js"; -import { createAgentsModule } from "./modules/agents.js"; +import { createAgentsModule } from "./modules/agents/index.js"; import { createAppLogsModule } from "./modules/app-logs.js"; import { createUsersModule } from "./modules/users.js"; import { RoomsSocket, RoomsSocketConfig } from "./utils/socket-utils.js"; @@ -189,6 +189,7 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, serverUrl, token, + getToken: () => token || getAccessToken() || undefined, }), appLogs: createAppLogsModule(axiosClient, appId), users: createUsersModule(axiosClient, appId), @@ -232,6 +233,7 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, serverUrl, token, + getToken: () => serviceToken, }), appLogs: createAppLogsModule(serviceRoleAxiosClient, appId), cleanup: () => { diff --git a/src/client.types.ts b/src/client.types.ts index 6b4c9c57..15d87aec 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -7,7 +7,7 @@ import type { UserConnectorsModule, } from "./modules/connectors.types.js"; import type { FunctionsModule } from "./modules/functions.types.js"; -import type { AgentsModule } from "./modules/agents.types.js"; +import type { AgentsModule } from "./modules/agents/agents.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; diff --git a/src/index.ts b/src/index.ts index bc531d89..74d9ce80 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { removeAccessToken, getLoginUrl, } from "./utils/auth-utils.js"; +import { tool } from "./modules/agents/tool.js"; export { createClient, @@ -21,6 +22,7 @@ export { saveAccessToken, removeAccessToken, getLoginUrl, + tool, }; export type { @@ -98,7 +100,7 @@ export type { AgentMessageCustomContext, AgentMessageMetadata, CreateConversationParams, -} from "./modules/agents.types.js"; +} from "./modules/agents/agents.types.js"; export type { AppLogsModule } from "./modules/app-logs.types.js"; @@ -115,6 +117,21 @@ export type { CustomIntegrationCallResponse, } from "./modules/custom-integrations.types.js"; +export type { + Tool, + JSONSchema, + ChatMessage, + Step, + RunUsage, + RunResult, + RunInput, + RunOptions, + ToolChoice, + AgentConfig, + Agent, + FinishReason, +} from "./modules/agents/agents.types.js"; + // Auth utils types export type { GetAccessTokenOptions, diff --git a/src/modules/agents.types.ts b/src/modules/agents/agents.types.ts similarity index 57% rename from src/modules/agents.types.ts rename to src/modules/agents/agents.types.ts index 49918c0c..19a7a8a7 100644 --- a/src/modules/agents.types.ts +++ b/src/modules/agents/agents.types.ts @@ -1,6 +1,319 @@ import { AxiosInstance } from "axios"; -import { RoomsSocket } from "../utils/socket-utils.js"; -import { ModelFilterParams } from "../types.js"; +import { RoomsSocket } from "../../utils/socket-utils.js"; +import { ModelFilterParams } from "../../types.js"; + +/** + * A JSON Schema object describing a tool's input parameters. + * Use the standard JSON Schema `object` shape: `{ type: "object", properties: {...}, required: [...] }`. + */ +export type JSONSchema = Record; + +/** + * A tool an agent can call. Create one with {@linkcode tool | tool()}, or derive it from a + * resource with `.asTool()`. + */ +export interface Tool { + /** Natural-language description the model uses to decide when to call the tool. */ + description: string; + /** JSON Schema for the tool's arguments. */ + parameters: JSONSchema; + /** Runs the tool. Receives parsed arguments; returns any JSON-serializable value (or a string). */ + execute: (args: any) => Promise | unknown; +} + +/** + * Why the agent run ended. + * `"max_steps"` is set by the SDK when the step cap is hit; all other values come from the model/normalization layer. + */ +export type FinishReason = "stop" | "length" | "tool-calls" | "content-filter" | "error" | "other"; + +/** An OpenAI-shaped chat message accepted by {@linkcode Agent.run}. */ +export interface ChatMessage { + role: "system" | "user" | "assistant" | "tool"; + content?: string | null; + tool_calls?: Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + }>; + tool_call_id?: string; +} + +/** One iteration of the agent loop: the tool calls the model made and their results. */ +export interface Step { + toolResults: Array<{ + toolCallId: string; + toolName: string; + args: unknown; + result: string; + }>; + /** Token/credit usage of the model call that produced this step's tool calls. */ + usage?: RunUsage; +} + +/** Token/credit usage for a single model call or a sum across calls. `credits` is the Base44 gateway's `base44_credits`. */ +export interface RunUsage { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + credits?: number; +} + +/** Result of {@linkcode Agent.run}. */ +export interface RunResult { + /** The model's final text output. */ + text: string; + /** The loop history (one entry per step that made tool calls). */ + steps: Step[]; + /** + * Why the run ended. + * Values come from the model/normalization layer (`"stop"`, `"length"`, `"tool-calls"`, `"content-filter"`, `"error"`, `"other"`) + * or from the SDK itself (`"max_steps"`, set when the step cap is reached). + */ + finishReason: FinishReason | "max_steps"; + /** Token and credit usage from the final completion. */ + usage: RunUsage; + /** Summed across all model calls in the loop; `usage` is the final call only. */ + totalUsage: RunUsage; + /** The raw final completion body, for advanced use. */ + raw: unknown; +} + +/** Input to {@linkcode Agent.run}: either a single prompt or a full message list. */ +export type RunInput = { prompt: string } | { messages: ChatMessage[] }; + +/** + * Per-run options passed as the second argument to {@linkcode Agent.run}. + * + * @example + * ```typescript + * const controller = new AbortController(); + * setTimeout(() => controller.abort(), 10_000); + * + * const result = await agent.run( + * { prompt: "Summarize last month's sales." }, + * { abortSignal: controller.signal }, + * ); + * ``` + */ +export interface RunOptions { + /** Abort the run (and the in-flight gateway request). */ + abortSignal?: AbortSignal; +} + +/** OpenAI-compatible tool choice. */ +export type ToolChoice = + | "auto" + | "none" + | "required" + | { type: "function"; function: { name: string } }; + +/** + * Configuration for a code-defined agent passed to {@linkcode AgentsModule.create}. + * + * @example + * ```typescript + * const agent = base44.agents.create({ + * model: "claude_sonnet_4_6", + * system: "You are a concise travel planner.", + * tools: { getWeather, searchFlights }, + * maxSteps: 5, + * }); + * ``` + */ +export interface AgentConfig { + /** + * Model alias or vendor model ID to use for this agent. + * + * Use a Base44 model alias (e.g. `"claude_sonnet_4_6"`, `"gpt_4o"`, `"gpt_5_mini"`) or a + * fully-qualified vendor ID. Available aliases are listed in the Base44 console. + */ + model: string; + /** + * System prompt prepended to every run. + * + * Provide instructions, persona, or constraints for the model. + * Omit to let the model run without a system message. + */ + system?: string; + /** + * Tools the agent may call, keyed by their function name. + * + * Each value must be a {@linkcode Tool} object — use the {@linkcode tool | tool()} factory + * to create one, or use `.asTool()` on a resource such as an entity or function. + * + * @example + * ```typescript + * import { tool } from "@base44/sdk"; + * + * const getWeather = tool({ + * description: "Get the current weather for a city.", + * parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, + * execute: async ({ city }) => fetch(`/weather?city=${city}`).then(r => r.json()), + * }); + * + * const agent = base44.agents.create({ model: "claude_sonnet_4_6", tools: { getWeather } }); + * ``` + */ + tools?: Record; + /** + * Maximum number of tool-calling loop iterations before the run stops. + * + * Each iteration is one round-trip to the model. If the model keeps calling tools + * and this limit is reached, the run ends with `finishReason: "max_steps"`. + * Defaults to `8`. + */ + maxSteps?: number; + /** + * Sampling temperature passed to the model. + * + * Controls output randomness: lower values (e.g. `0`) produce more deterministic + * responses; higher values (e.g. `1`) increase variety. Omit to use the model default. + * + * Note: GPT-5 series models only accept `temperature: 1`. + */ + temperature?: number; + /** + * JSON Schema to constrain the model's output to structured JSON. + * + * When set, the request is sent with `response_format: { type: "json_schema", … }`. + * The model's response will be valid JSON matching the schema; access it by parsing + * `RunResult.text`. + * The schema is sent with strict JSON-schema mode, so it should have `additionalProperties: false` and list every property in `required` (otherwise the provider may reject it). + * + * @example + * ```typescript + * const agent = base44.agents.create({ + * model: "claude_sonnet_4_6", + * responseFormat: { + * type: "object", + * properties: { summary: { type: "string" }, score: { type: "number" } }, + * required: ["summary", "score"], + * }, + * }); + * const { text } = await agent.run({ prompt: "Rate this product description." }); + * const { summary, score } = JSON.parse(text); + * ``` + */ + responseFormat?: JSONSchema; + /** + * Controls whether and which tool the model must call. + * + * - `"auto"` (default when tools are provided): the model decides. + * - `"none"`: the model must not call any tool. + * - `"required"`: the model must call at least one tool. + * - `{ type: "function", function: { name } }`: force a specific tool. + */ + toolChoice?: ToolChoice; +} + +/** + * A reusable code-defined agent returned by {@linkcode AgentsModule.create}. + * + * An agent runs a multi-step tool-calling loop: it sends messages to the model, + * executes any tool calls the model requests, feeds the results back, and repeats + * until the model produces a final answer or `maxSteps` is reached. + * + * @example + * ```typescript + * const agent = base44.agents.create({ + * model: "claude_sonnet_4_6", + * system: "You are a concise travel planner.", + * tools: { getWeather }, + * }); + * + * const { text } = await agent.run({ prompt: "What's the weather like in Tel Aviv?" }); + * console.log(text); + * ``` + */ +export interface Agent { + /** + * Runs the agent's tool-calling loop to completion and returns the final result. + * + * Builds an initial message list from `input`, then repeatedly calls the model, + * executes any tool calls, and feeds results back until the model stops or + * `maxSteps` (from {@linkcode AgentConfig}) is reached. + * + * Tool errors are fed back to the model as tool results rather than thrown, so + * the model can recover or explain the failure. + * + * @param input - The run input: either `{ prompt: string }` for a simple user + * message, or `{ messages: ChatMessage[] }` to supply a full conversation history. + * @param options - Optional {@linkcode RunOptions} (e.g. an `AbortSignal`). + * @returns Promise resolving to a {@linkcode RunResult} containing the model's + * final text, per-step tool call history, finish reason, and token/credit usage. + * + * @example + * ```typescript + * // Simple prompt + * const { text, usage } = await agent.run({ prompt: "Plan a one-day trip to Haifa." }); + * console.log(text); + * console.log(`Credits used: ${usage.credits}`); + * ``` + * + * @example + * ```typescript + * // Supply a full message history + * const { text } = await agent.run({ + * messages: [ + * { role: "user", content: "What is the capital of France?" }, + * { role: "assistant", content: "Paris." }, + * { role: "user", content: "And what is the population?" }, + * ], + * }); + * ``` + * + * @example + * ```typescript + * // Cancel a long-running run + * const controller = new AbortController(); + * setTimeout(() => controller.abort(), 15_000); + * const { text } = await agent.run( + * { prompt: "Summarize all open support tickets." }, + * { abortSignal: controller.signal }, + * ); + * ``` + */ + run(input: RunInput, options?: RunOptions): Promise; + + /** + * Wraps this agent as a {@linkcode Tool} so another agent can call it as a sub-agent. + * + * The returned tool exposes a single `prompt` parameter. When called, it invokes + * {@linkcode Agent.run | run()} with that prompt and returns the text result. + * Use this to build agent hierarchies where a coordinator agent delegates tasks + * to specialized sub-agents. + * + * @param opts - Options for the tool wrapper. + * @param opts.description - Required. Natural-language description the calling + * model uses to decide when to invoke this sub-agent. + * @param opts.name - Optional display name for the tool. Defaults to the + * agent config's model alias when omitted. + * @returns A {@linkcode Tool} that can be passed in another agent's `tools` map. + * @note The sub-agent runs independently — the parent's `abortSignal` is not propagated and + * the sub-agent's token/credit usage is not included in the parent's `totalUsage`. + * + * @example + * ```typescript + * const researchAgent = base44.agents.create({ + * model: "claude_sonnet_4_6", + * tools: { webSearch }, + * }); + * + * const writerAgent = base44.agents.create({ + * model: "claude_sonnet_4_6", + * tools: { + * research: researchAgent.asTool({ + * description: "Search the web and return a research summary.", + * }), + * }, + * }); + * + * const { text } = await writerAgent.run({ prompt: "Write a blog post about coral reefs." }); + * ``` + */ + asTool(opts: { name?: string; description: string }): Tool; +} /** * Registry of agent names. The [`types generate`](/developers/references/cli/commands/types-generate) command fills this registry, then [`AgentName`](#agentname) resolves to a union of the keys. @@ -174,6 +487,8 @@ export interface AgentsModuleConfig { serverUrl?: string; /** Authentication token */ token?: string; + /** Returns the current bearer token at call time (thunk — never a captured string). Used by `create()`. */ + getToken?: () => string | undefined; } /** @@ -387,6 +702,28 @@ export interface AgentsModule { onUpdate?: (conversation: AgentConversation) => void ): () => void; + /** + * Creates a code-defined agent: you specify the model, system prompt, and tools in code, + * and the SDK runs the tool-calling loop against the Base44 AI Gateway. + * + * Returns a reusable {@linkcode Agent} you can {@linkcode Agent.run | run} or expose to + * another agent as a tool with {@linkcode Agent.asTool | asTool}. + * + * @param config - Model alias, optional system prompt, tools, and step limit. + * @returns A reusable {@linkcode Agent} with {@linkcode Agent.run | run()} and {@linkcode Agent.asTool | asTool()}. + * + * @example + * ```typescript + * const agent = base44.agents.create({ + * model: "claude_sonnet_4_6", + * system: "You plan trips.", + * tools: { getWeather }, + * }); + * const { text } = await agent.run({ prompt: "Plan a day in Haifa." }); + * ``` + */ + create(config: AgentConfig): Agent; + /** * Gets WhatsApp connection URL for an agent. * diff --git a/src/modules/agents/gateway.ts b/src/modules/agents/gateway.ts new file mode 100644 index 00000000..0d209a49 --- /dev/null +++ b/src/modules/agents/gateway.ts @@ -0,0 +1,73 @@ +import { Base44Error } from "../../utils/axios-client.js"; + +/** + * Minimal authenticated JSON POST against the gateway. The provider supplies the wire-specific + * path (e.g. `/chat/completions`), so the transport stays neutral across provider formats. + * @internal + */ +export type GatewayTransport = { + post(path: string, body: Record, opts?: { signal?: AbortSignal }): Promise; +}; + +/** @internal */ +export interface GatewayConfig { + serverUrl: string; + /** Returns the current bearer token at call time (thunk — never a captured string). */ + getToken: () => string | undefined; +} + +/** + * Resolves the AI Gateway connection from a client config. + * @internal + */ +export function resolveConnection(config: GatewayConfig): { + baseURL: string; + apiKey: string; +} { + const { serverUrl, getToken } = config; + // No appId in the path: the gateway resolves the app by request Host. + return { + baseURL: `${serverUrl}/api/ai/unified/v1`, + apiKey: getToken() ?? "", + }; +} + +/** + * Creates the gateway transport — owns the single HTTP call to the OpenAI-compatible + * `/chat/completions` endpoint. + * @internal + */ +export function createGatewayTransport(config: GatewayConfig) { + return { + async post( + path: string, + body: Record, + opts: { signal?: AbortSignal } = {} + ): Promise { + const { baseURL, apiKey } = resolveConnection(config); + const res = await fetch(`${baseURL}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + signal: opts.signal, + }); + + const json = await res.json().catch(() => null); + + if (!res.ok) { + const err = (json && json.error) || {}; + throw new Base44Error( + err.message || `AI Gateway request failed with status ${res.status}`, + res.status, + err.code || err.type || "ai_gateway_error", + json, + null + ); + } + return json; + }, + }; +} diff --git a/src/modules/agents.ts b/src/modules/agents/index.ts similarity index 88% rename from src/modules/agents.ts rename to src/modules/agents/index.ts index 015261fc..dea8709d 100644 --- a/src/modules/agents.ts +++ b/src/modules/agents/index.ts @@ -1,5 +1,5 @@ -import { getAccessToken } from "../utils/auth-utils.js"; -import { ModelFilterParams } from "../types.js"; +import { getAccessToken } from "../../utils/auth-utils.js"; +import { ModelFilterParams } from "../../types.js"; import { AgentConversation, AgentMessage, @@ -7,6 +7,9 @@ import { AgentsModuleConfig, CreateConversationParams, } from "./agents.types.js"; +import { createGatewayTransport } from "./gateway.js"; +import { openAICompatibleProvider } from "./providers/openai-compatible.js"; +import { createAgent } from "./loop.js"; export function createAgentsModule({ axios, @@ -14,7 +17,12 @@ export function createAgentsModule({ appId, serverUrl, token, + getToken, }: AgentsModuleConfig): AgentsModule { + const model = openAICompatibleProvider(createGatewayTransport({ + serverUrl: serverUrl ?? "", + getToken: getToken ?? (() => token), + })); const baseURL = `/apps/${appId}/agents`; // Track active conversations @@ -135,5 +143,6 @@ export function createAgentsModule({ subscribeToConversation, getWhatsAppConnectURL, getTelegramConnectURL, + create(config) { return createAgent(config, model); }, }; } diff --git a/src/modules/agents/loop.ts b/src/modules/agents/loop.ts new file mode 100644 index 00000000..84c49320 --- /dev/null +++ b/src/modules/agents/loop.ts @@ -0,0 +1,155 @@ +import type { Agent, AgentConfig, RunInput, RunOptions, RunResult, RunUsage, Step, Tool } from "./agents.types.js"; +import type { LanguageModel, ModelMessage } from "./provider.js"; + +const DEFAULT_MAX_STEPS = 8; + +function safeParseJson(json: string | undefined | null): unknown { + try { + return JSON.parse(json || "{}"); + } catch { + return {}; + } +} + +function inputToMessages(input: RunInput): ModelMessage[] { + if (!("messages" in input)) { + return [{ role: "user", content: input.prompt }]; + } + // Map the public ChatMessage[] 1:1 to neutral ModelMessage[] by role. + return input.messages.map((message): ModelMessage => { + if (message.role === "system") { + return { role: "system", content: typeof message.content === "string" ? message.content : "" }; + } + if (message.role === "user") { + return { role: "user", content: typeof message.content === "string" ? message.content : "" }; + } + if (message.role === "assistant") { + const toolCalls = message.tool_calls?.map((call) => ({ + id: call.id, + name: call.function.name, + args: safeParseJson(call.function.arguments), + })); + return { + role: "assistant", + content: message.content ?? undefined, + toolCalls: toolCalls?.length ? toolCalls : undefined, + }; + } + // tool + return { + role: "tool", + toolCallId: (message as { tool_call_id?: string }).tool_call_id ?? "", + result: typeof message.content === "string" ? message.content : "", + }; + }); +} + +function stringifyToolResult(value: unknown): string { + return typeof value === "string" ? value : JSON.stringify(value); +} + +function sumUsage(accumulated: RunUsage, next: RunUsage): RunUsage { + return { + inputTokens: (accumulated.inputTokens ?? 0) + (next.inputTokens ?? 0), + outputTokens: (accumulated.outputTokens ?? 0) + (next.outputTokens ?? 0), + totalTokens: (accumulated.totalTokens ?? 0) + (next.totalTokens ?? 0), + credits: (accumulated.credits ?? 0) + (next.credits ?? 0), + }; +} + +/** Creates an Agent from a config and a language model. @internal */ +export function createAgent(agentConfig: AgentConfig, model: LanguageModel): Agent { + const maxSteps = agentConfig.maxSteps ?? DEFAULT_MAX_STEPS; + const tools = agentConfig.tools; + + const agent: Agent = { + async run(input: RunInput, options: RunOptions = {}): Promise { + const messages: ModelMessage[] = [ + ...(agentConfig.system ? [{ role: "system" as const, content: agentConfig.system }] : []), + ...inputToMessages(input), + ]; + const steps: Step[] = []; + let totalUsage: RunUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, credits: 0 }; + // The most recent model call; referenced after the loop for the max-steps return. + let modelResult: Awaited> | null = null; + + for (let step = 0; step < maxSteps; step++) { + modelResult = await model.generate({ + model: agentConfig.model, + messages, + tools, + temperature: agentConfig.temperature, + toolChoice: agentConfig.toolChoice, + responseFormat: agentConfig.responseFormat, + signal: options.abortSignal, + }); + totalUsage = sumUsage(totalUsage, modelResult.usage ?? {}); + + messages.push({ + role: "assistant", + content: modelResult.text || undefined, + toolCalls: modelResult.toolCalls.length ? modelResult.toolCalls : undefined, + }); + + // No tool calls: the model is done — return its final answer. + if (modelResult.toolCalls.length === 0) { + return { + text: modelResult.text, + steps, + finishReason: modelResult.finishReason, + usage: modelResult.usage, + totalUsage, + raw: modelResult.raw, + }; + } + + // Execute each requested tool and feed the result back for the next turn. + const toolResults: Step["toolResults"] = []; + for (const call of modelResult.toolCalls) { + const matchedTool = tools?.[call.name]; + let resultContent: string; + if (!matchedTool) { + resultContent = `Error: tool "${call.name}" is not available.`; + } else { + try { + resultContent = stringifyToolResult(await matchedTool.execute(call.args)); + } catch (error: unknown) { + const message = (error as { message?: string })?.message; + resultContent = `Error: ${message ?? String(error)}`; + } + } + messages.push({ role: "tool", toolCallId: call.id, toolName: call.name, result: resultContent }); + toolResults.push({ toolCallId: call.id, toolName: call.name, args: call.args, result: resultContent }); + } + steps.push({ toolResults, usage: modelResult.usage }); + } + + // Loop exhausted without a final (tool-free) answer. + return { + text: modelResult?.text ?? "", + steps, + finishReason: "max_steps", + usage: modelResult?.usage ?? {}, + totalUsage, + raw: modelResult?.raw ?? null, + }; + }, + + asTool(toolOpts: { name?: string; description: string }): Tool { + return { + description: toolOpts.description, + parameters: { + type: "object", + properties: { prompt: { type: "string", description: "What to ask the sub-agent." } }, + required: ["prompt"], + }, + execute: async (args: { prompt: string }) => { + const result = await agent.run({ prompt: args.prompt }); + return result.text; + }, + }; + }, + }; + + return agent; +} diff --git a/src/modules/agents/provider.ts b/src/modules/agents/provider.ts new file mode 100644 index 00000000..e003e214 --- /dev/null +++ b/src/modules/agents/provider.ts @@ -0,0 +1,43 @@ +import type { Tool, ToolChoice, JSONSchema, RunUsage, FinishReason } from "./agents.types.js"; + +/** A parsed tool call the model wants to make. Args are already parsed (object), never a JSON string. @internal */ +export interface ModelToolCall { id: string; name: string; args: unknown } + +/** + * Neutral, provider-agnostic conversation message. `system` is a message role here; + * each provider adapter places it where its wire format expects. `content` is a string. + * @internal + */ +export type ModelMessage = + | { role: "system"; content: string } + | { role: "user"; content: string } + | { role: "assistant"; content?: string; toolCalls?: ModelToolCall[] } + | { role: "tool"; toolCallId: string; toolName?: string; result: string }; + +/** A request to a language model. @internal */ +export interface GenerateRequest { + model: string; + messages: ModelMessage[]; + tools?: Record; + temperature?: number; + toolChoice?: ToolChoice; + responseFormat?: JSONSchema; + signal?: AbortSignal; +} + +/** Normalized model output. @internal */ +export interface GenerateResult { + text: string; + toolCalls: ModelToolCall[]; + finishReason: FinishReason; + usage: RunUsage; + /** Opaque vendor-specific extras (cache control, reasoning, safety, …). @internal */ + providerMetadata?: Record; + /** The raw vendor response, for advanced use. */ + raw: unknown; +} + +/** The provider seam. Adapters translate neutral <-> vendor wire. @internal */ +export interface LanguageModel { + generate(req: GenerateRequest): Promise; +} diff --git a/src/modules/agents/providers/openai-compatible.ts b/src/modules/agents/providers/openai-compatible.ts new file mode 100644 index 00000000..06417e68 --- /dev/null +++ b/src/modules/agents/providers/openai-compatible.ts @@ -0,0 +1,104 @@ +import type { Tool, JSONSchema, RunUsage, FinishReason } from "../agents.types.js"; +import type { GenerateRequest, GenerateResult, LanguageModel, ModelMessage, ModelToolCall } from "../provider.js"; +import type { GatewayTransport } from "../gateway.js"; + +interface ChatCompletionToolDef { type: "function"; function: { name: string; description: string; parameters: JSONSchema } } + +function serializeTools(tools?: Record): ChatCompletionToolDef[] | undefined { + if (!tools) return undefined; + const entries = Object.entries(tools); + if (entries.length === 0) return undefined; + return entries.map(([name, toolDef]) => ({ + type: "function", + function: { name, description: toolDef.description, parameters: toolDef.parameters }, + })); +} + +/** Neutral messages -> Chat Completions messages. System role in the array is passed through. */ +function toChatMessages(messages: ModelMessage[]): Record[] { + const chatMessages: Record[] = []; + for (const message of messages) { + if (message.role === "system") { + chatMessages.push({ role: "system", content: message.content }); + } else if (message.role === "user") { + chatMessages.push({ role: "user", content: message.content }); + } else if (message.role === "assistant") { + const assistantMessage: Record = { role: "assistant", content: message.content ?? null }; + if (message.toolCalls?.length) { + assistantMessage.tool_calls = message.toolCalls.map((call) => ({ + id: call.id, + type: "function", + function: { name: call.name, arguments: JSON.stringify(call.args ?? {}) }, + })); + } + chatMessages.push(assistantMessage); + } else { + chatMessages.push({ role: "tool", tool_call_id: message.toolCallId, content: message.result }); + } + } + return chatMessages; +} + +/** Build the Chat Completions body using the param whitelist (rejected params can never appear). */ +function buildChatCompletionsBody(req: GenerateRequest): Record { + const body: Record = { model: req.model, messages: toChatMessages(req.messages) }; + if (req.temperature !== undefined) body.temperature = req.temperature; + if (req.toolChoice !== undefined) body.tool_choice = req.toolChoice; + if (req.responseFormat !== undefined) { + body.response_format = { type: "json_schema", json_schema: { name: "response", schema: req.responseFormat, strict: true } }; + } + const tools = serializeTools(req.tools); + if (tools) body.tools = tools; + return body; +} + +const FINISH: Record = { + stop: "stop", length: "length", tool_calls: "tool-calls", content_filter: "content-filter", +}; +function normalizeFinish(raw: string | undefined, hasToolCalls: boolean): FinishReason { + if (hasToolCalls) return "tool-calls"; + if (raw !== undefined && FINISH[raw]) return FINISH[raw]; + return "other"; +} + +function parseChatCompletion(raw: any): GenerateResult { + const choice = raw?.choices?.[0]; + const message = choice?.message ?? {}; + const toolCalls: ModelToolCall[] = (message.tool_calls ?? []).map((call: any) => { + let args: unknown = {}; + try { + args = JSON.parse(call.function?.arguments || "{}"); + } catch { + args = {}; + } + return { id: call.id, name: call.function?.name, args }; + }); + const rawUsage = raw?.usage ?? {}; + const usage: RunUsage = { + inputTokens: rawUsage.prompt_tokens, + outputTokens: rawUsage.completion_tokens, + totalTokens: rawUsage.total_tokens, + credits: rawUsage.base44_credits, + }; + return { + text: message.content ?? "", + toolCalls, + finishReason: normalizeFinish(choice?.finish_reason, toolCalls.length > 0), + usage, + raw, + }; +} + +/** + * OpenAI-compatible provider: speaks the Chat Completions wire format over the Base44 + * gateway transport. + * @internal + */ +export function openAICompatibleProvider(transport: GatewayTransport): LanguageModel { + return { + async generate(req: GenerateRequest): Promise { + const raw = await transport.post("/chat/completions", buildChatCompletionsBody(req), { signal: req.signal }); + return parseChatCompletion(raw); + }, + }; +} diff --git a/src/modules/agents/tool.ts b/src/modules/agents/tool.ts new file mode 100644 index 00000000..be79f2de --- /dev/null +++ b/src/modules/agents/tool.ts @@ -0,0 +1,40 @@ +import type { Tool } from "./agents.types.js"; + +/** + * Defines a tool an agent can call. + * + * A tool combines a natural-language `description` (used by the model to decide + * when to call the tool), a JSON Schema for its `parameters`, and an `execute` + * function that runs when the model calls it. + * + * Pass the returned tool in the `tools` map of {@linkcode AgentsModule.create | base44.agents.create()}. + * + * @param t - The tool definition: `description`, `parameters` (JSON Schema), and `execute`. + * @returns The same tool object, typed as {@linkcode Tool}. + * + * @example + * ```typescript + * import { tool } from "@base44/sdk"; + * + * const getWeather = tool({ + * description: "Get the current weather for a city.", + * parameters: { + * type: "object", + * properties: { city: { type: "string", description: "City name, e.g. 'Tel Aviv'" } }, + * required: ["city"], + * }, + * execute: async ({ city }) => { + * const data = await fetchWeatherAPI(city); + * return { city, tempC: data.temperature }; + * }, + * }); + * + * const agent = base44.agents.create({ + * model: "claude_sonnet_4_6", + * tools: { getWeather }, + * }); + * ``` + */ +export function tool(t: Tool): Tool { + return t; +} diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 1eaaf287..048827dc 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -13,6 +13,7 @@ import { UpdateManyResult, } from "./entities.types"; import { RoomsSocket } from "../utils/socket-utils.js"; +import type { Tool } from "./agents/agents.types.js"; /** * Configuration for the entities module. @@ -93,7 +94,7 @@ function createEntityHandler( ): EntityHandler { const baseURL = `/apps/${appId}/entities/${entityName}`; - return { + const handler: EntityHandler = { // List entities with optional pagination and sorting async list( sort?: SortField, @@ -223,5 +224,65 @@ function createEntityHandler( return unsubscribe; }, + + asTool(opts: { operations?: ("read" | "create" | "update" | "delete")[] } = {}): Record { + const operations = opts.operations ?? ["read"]; + const tools: Record = {}; + + if (operations.includes("read")) { + tools[`read_${entityName}`] = { + description: `Read ${entityName} entities. For the query param, use MongoDB query syntax, e.g. { "status": "open", "price": { "$gt": 30 } }.`, + parameters: { + type: "object", + properties: { + query: { type: "object", description: `MongoDB-style filter over ${entityName} fields.`, additionalProperties: true }, + sort: { type: "string", description: "Field to sort by; prefix with '-' for descending (e.g. '-created_date')." }, + limit: { type: "number", description: "Maximum number of records to return." }, + skip: { type: "number", description: "Number of records to skip (pagination)." }, + fields: { type: "array", items: { type: "string" }, description: "Subset of fields to return." }, + }, + }, + execute: (args: { query?: Record; sort?: string; limit?: number; skip?: number; fields?: string[] } = {}) => + handler.filter((args.query ?? {}) as EntityFilterQuery, args.sort as SortField | undefined, args.limit, args.skip, args.fields as (keyof T)[] | undefined), + }; + } + if (operations.includes("create")) { + tools[`create_${entityName}`] = { + description: `Create a new ${entityName} entity`, + // open object: the SDK has no runtime schema, so the model supplies fields directly + parameters: { type: "object", additionalProperties: true }, + execute: (args: Record = {}) => handler.create(args as Partial), + }; + } + if (operations.includes("update")) { + tools[`update_${entityName}`] = { + description: `Update an existing ${entityName} entity`, + parameters: { + type: "object", + properties: { id: { type: "string", description: `The id of the ${entityName} to update.` } }, + required: ["id"], + additionalProperties: true, + }, + execute: (args: { id: string } & Record) => { + const { id, ...data } = args ?? ({} as { id: string }); + return handler.update(id, data as Partial); + }, + }; + } + if (operations.includes("delete")) { + tools[`delete_${entityName}`] = { + description: `Delete an existing ${entityName} entity`, + parameters: { + type: "object", + properties: { id: { type: "string", description: `The id of the ${entityName} to delete.` } }, + required: ["id"], + }, + execute: (args: { id: string }) => handler.delete(args.id), + }; + } + return tools; + }, }; + + return handler; } diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index c5854dd3..a7f74ded 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -1,3 +1,5 @@ +import type { Tool } from "./agents/agents.types.js"; + /** * Event types for realtime entity updates. */ @@ -696,6 +698,26 @@ export interface EntityHandler { * ``` */ subscribe(callback: RealtimeCallback): () => void; + + /** + * Turns this entity into a map of agent tools — one per allowed operation + * (`read_`, `create_`, `update_`, `delete_`). + * Read-only by default, pass `operations` to opt into writes. Spread the result into an agent's `tools`. + * + * @param opts - Optional `operations` (`"read" | "create" | "update" | "delete"`, default `["read"]`). + * @returns A map of tool-name to {@linkcode Tool}. Keys follow the pattern `read_`, `create_`, `update_`, `delete_`. + * + * @example + * ```typescript + * const agent = base44.agents.create({ + * model: "claude_sonnet_4_6", + * tools: { ...base44.entities.Order.asTool({ operations: ["read", "update"] }) }, + * }); + * ``` + */ + asTool(opts?: { + operations?: ("read" | "create" | "update" | "delete")[]; + }): Record; } /** diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 1a72c1d3..1e4432ab 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -42,9 +42,9 @@ export function createFunctionsModule( return headers; }; - return { - // Invoke a custom backend function by name - async invoke(functionName: string, data: Record) { + // Hoisted so both the returned `invoke` property and `asTool.execute` can reference it + // without relying on `this` (which breaks when the object is spread into the client). + const invoke = async (functionName: string, data: Record) => { // Validate input if (typeof data === "string") { throw new Error( @@ -81,7 +81,10 @@ export function createFunctionsModule( formData || data, { headers: { "Content-Type": contentType } } ); - }, + }; + + return { + invoke, // Fetch a backend function endpoint directly. async fetch(path: string, init: FunctionsFetchInit = {}) { @@ -102,5 +105,17 @@ export function createFunctionsModule( return response; }, + + // Turn a backend function into an agent tool. + asTool(name: string, opts: { description: string; parameters?: Record }) { + return { + description: opts.description, + parameters: opts.parameters ?? { type: "object", properties: {}, additionalProperties: true }, + execute: async (args: Record) => { + const res: any = await invoke(name, args ?? {}); + return res?.data; + }, + }; + }, }; } diff --git a/src/modules/functions.types.ts b/src/modules/functions.types.ts index b4e8b305..2efb9caf 100644 --- a/src/modules/functions.types.ts +++ b/src/modules/functions.types.ts @@ -1,3 +1,5 @@ +import type { Tool } from "./agents/agents.types.js"; + /** * Registry of function names. The [`types generate`](/developers/references/cli/commands/types-generate) command fills this registry, then [`FunctionName`](#functionname) resolves to a union of the keys. */ @@ -92,6 +94,31 @@ export interface FunctionsModule { */ invoke(functionName: FunctionName, data?: Record): Promise; + /** + * Turns a backend function into a {@linkcode Tool} an agent can call. + * + * Functions are invoked by name with no server-known input schema, so you supply a + * `description` and (optionally) JSON Schema `parameters` for the model. + * + * @param name - The backend function name. + * @param opts - `description` (required) and optional JSON Schema `parameters`. + * @returns A {@linkcode Tool} for use in an agent's `tools` map. + * + * @example + * ```typescript + * const emailTool = base44.functions.asTool("sendOrderEmail", { + * description: "Send an order confirmation email to the customer.", + * parameters: { + * type: "object", + * properties: { orderId: { type: "string" } }, + * required: ["orderId"], + * }, + * }); + * const agent = base44.agents.create({ model: "claude_sonnet_4_6", tools: { emailTool } }); + * ``` + */ + asTool(name: FunctionName, opts: { description: string; parameters?: Record }): Tool; + /** * Performs a direct HTTP request to a backend function path and returns the native `Response`. * diff --git a/src/modules/types.ts b/src/modules/types.ts index c7423e66..f3ee2b62 100644 --- a/src/modules/types.ts +++ b/src/modules/types.ts @@ -1,4 +1,4 @@ export * from "./app.types.js"; -export * from "./agents.types.js"; +export * from "./agents/agents.types.js"; export * from "./connectors.types.js"; export * from "./analytics.types.js"; \ No newline at end of file diff --git a/tests/types/agents-code.types.ts b/tests/types/agents-code.types.ts new file mode 100644 index 00000000..09f4adc7 --- /dev/null +++ b/tests/types/agents-code.types.ts @@ -0,0 +1,84 @@ +import type { RunInput, ToolChoice, Agent, ChatMessage, AgentsModule, RunResult, RunUsage } from "../../src/index.js"; + +// --------------------------------------------------------------------------- +// RunInput — union of { prompt: string } | { messages: ChatMessage[] } +// --------------------------------------------------------------------------- + +const promptInput = { prompt: "Plan a day in Haifa." } satisfies RunInput; + +const messagesInput = { + messages: [{ role: "user" as const, content: "What is the capital of France?" }], +} satisfies RunInput; + +const messagesArrayInput = { + messages: [ + { role: "user" as const, content: "Hello" }, + { role: "assistant" as const, content: "Hi there!" }, + ] satisfies ChatMessage[], +} satisfies RunInput; + +const rejectsEmptyRunInput = { + // @ts-expect-error RunInput requires either prompt or messages — empty object is invalid. +} satisfies RunInput; + +const rejectsRunInputWithWrongField = { + // @ts-expect-error RunInput does not accept a 'query' field. + query: "something", +} satisfies RunInput; + +// --------------------------------------------------------------------------- +// ToolChoice — "auto" | "none" | "required" | { type: "function"; function: { name: string } } +// --------------------------------------------------------------------------- + +const toolChoiceAuto = "auto" satisfies ToolChoice; +const toolChoiceNone = "none" satisfies ToolChoice; +const toolChoiceRequired = "required" satisfies ToolChoice; +const toolChoiceFunction = { + type: "function" as const, + function: { name: "getWeather" }, +} satisfies ToolChoice; + +const rejectsBadStringToolChoice = ( + // @ts-expect-error "always" is not a valid ToolChoice string. + "always" satisfies ToolChoice +); + +const rejectsMissingFunctionName = ( + // @ts-expect-error function.name is required in the object form of ToolChoice. + { type: "function", function: {} } satisfies ToolChoice +); + +// --------------------------------------------------------------------------- +// base44.agents.create() return type — Agent exposes run() and asTool() +// --------------------------------------------------------------------------- + +// Exercise the real public method: AgentsModule.create's declared return type +// must be assignable to Agent (and accept a minimal config). +declare const agents: AgentsModule; + +const agent: Agent = agents.create({ model: "claude_sonnet_4_6" }); + +// run exists and returns a Promise +const _runResult: ReturnType = agent.run({ prompt: "hello" }); + +// asTool exists and accepts opts with required description +const _tool = agent.asTool({ description: "A helpful sub-agent." }); +const _toolWithName = agent.asTool({ name: "helper", description: "A helpful sub-agent." }); + +// asTool requires description +const rejectsAsToolWithoutDescription = agent.asTool( + // @ts-expect-error description is required by asTool. + {} +); + +// RunResult must have usage and totalUsage of type RunUsage +declare const runResult: RunResult; +const _usage: RunUsage = runResult.usage; +const _totalUsage: RunUsage = runResult.totalUsage; + +// AgentConfig rejects non-number temperature +agents.create({ + model: "m", + // @ts-expect-error temperature must be a number, not a string. + temperature: "hot", +}); diff --git a/tests/types/entities-as-tool.types.ts b/tests/types/entities-as-tool.types.ts new file mode 100644 index 00000000..ce31a61b --- /dev/null +++ b/tests/types/entities-as-tool.types.ts @@ -0,0 +1,19 @@ +import type { Base44Client } from "../../src/index.js"; + +declare const base44: Base44Client; + +// asTool returns a Record (one per allowed operation) +const tools = base44.entities.Order.asTool({ operations: ["read", "update"] }); +const _read = tools["read_Order"]; +const _desc: string = _read.description; + +// default (no args) is allowed +const _readOnly = base44.entities.Order.asTool(); + +// @ts-expect-error operations must be from the allowed union +base44.entities.Order.asTool({ operations: ["purge"] }); + +import type { Tool } from "../../src/index.js"; + +// A returned entry is assignable to Tool +const _t: Tool = tools["read_Order"]; diff --git a/tests/unit/agents-code.test.ts b/tests/unit/agents-code.test.ts new file mode 100644 index 00000000..96f84062 --- /dev/null +++ b/tests/unit/agents-code.test.ts @@ -0,0 +1,491 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { createClient } from "../../src/index.ts"; +import * as sdk from "../../src/index.ts"; +import { Base44Error } from "../../src/index.ts"; +import { resolveConnection, createGatewayTransport } from "../../src/modules/agents/gateway.ts"; +import { tool } from "../../src/modules/agents/tool.ts"; +import { createAgent } from "../../src/modules/agents/loop.ts"; +import { openAICompatibleProvider } from "../../src/modules/agents/providers/openai-compatible.ts"; + +const config = { + serverUrl: "https://app-1.base44.app", + getToken: () => "tok-123", +}; + +// --------------------------------------------------------------------------- +// Helper: build a mock completion response +// --------------------------------------------------------------------------- + +function completion(opts: { + content?: string | null; + toolCalls?: Array<{ id: string; name: string; arguments: string }>; + finish?: string; + usage?: Record; +}) { + const message: any = { role: "assistant", content: opts.content ?? null }; + if (opts.toolCalls) { + message.tool_calls = opts.toolCalls.map((c) => ({ + id: c.id, + type: "function", + function: { name: c.name, arguments: c.arguments }, + })); + } + return new Response( + JSON.stringify({ + id: "cmpl", + choices: [{ index: 0, message, finish_reason: opts.finish ?? "stop" }], + usage: opts.usage ?? { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, base44_credits: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); +} + +// --------------------------------------------------------------------------- +// AI Gateway transport +// --------------------------------------------------------------------------- + +describe("AI Gateway transport", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + test("should build the gateway baseURL and apiKey from config", () => { + expect(resolveConnection(config)).toEqual({ + baseURL: "https://app-1.base44.app/api/ai/unified/v1", + apiKey: "tok-123", + }); + }); + + test("should POST to /chat/completions with bearer auth and return parsed body", async () => { + const body = { model: "gpt_5_mini", messages: [{ role: "user", content: "hi" }] }; + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ id: "x", choices: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const transport = createGatewayTransport(config); + const result = await transport.post("/chat/completions", body); + + expect(result).toEqual({ id: "x", choices: [] }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://app-1.base44.app/api/ai/unified/v1/chat/completions"); + expect(init.method).toBe("POST"); + expect((init.headers as Record).Authorization).toBe("Bearer tok-123"); + expect((init.headers as Record)["Content-Type"]).toBe("application/json"); + expect(JSON.parse(init.body as string)).toEqual(body); + }); + + test("should map an OpenAI error envelope to a Base44Error", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + error: { message: "insufficient quota", type: "insufficient_quota", code: null, param: null }, + }), + { status: 402, headers: { "Content-Type": "application/json" } } + ) + ); + const transport = createGatewayTransport(config); + await expect(transport.post("/chat/completions", { model: "m", messages: [] })).rejects.toMatchObject({ + name: "Base44Error", + status: 402, + message: "insufficient quota", + }); + await expect(transport.post("/chat/completions", { model: "m", messages: [] })).rejects.toBeInstanceOf(Base44Error); + }); +}); + +// --------------------------------------------------------------------------- +// tool() +// --------------------------------------------------------------------------- + +describe("tool()", () => { + test("should return its argument unchanged", () => { + const t = { description: "d", parameters: { type: "object" }, execute: () => 1 }; + expect(tool(t)).toBe(t); + }); +}); + +// --------------------------------------------------------------------------- +// Agent loop (createAgent) +// --------------------------------------------------------------------------- + +describe("Agent loop", () => { + let fetchMock: ReturnType; + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + test("should return text, usage (incl. credits), and finishReason on a no-tool completion", async () => { + fetchMock.mockResolvedValue(completion({ content: "Hello there." })); + const transport = createGatewayTransport(config); + const agent = createAgent({ model: "gpt_5_mini", system: "Be terse." }, openAICompatibleProvider(transport)); + const result = await agent.run({ prompt: "Hi" }); + + expect(result.text).toBe("Hello there."); + expect(result.finishReason).toBe("stop"); + expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 5, totalTokens: 15, credits: 2 }); + expect(result.totalUsage).toEqual(result.usage); + expect(result.steps).toEqual([]); + // system + user were sent + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.messages).toEqual([ + { role: "system", content: "Be terse." }, + { role: "user", content: "Hi" }, + ]); + }); + + test("should execute a tool then continue to a final answer", async () => { + fetchMock + .mockResolvedValueOnce( + completion({ toolCalls: [{ id: "call_1", name: "getWeather", arguments: '{"city":"Haifa"}' }], finish: "tool_calls" }) + ) + .mockResolvedValueOnce(completion({ content: "It's sunny in Haifa." })); + + const execute = vi.fn(async ({ city }: { city: string }) => ({ city, condition: "sunny" })); + const transport = createGatewayTransport(config); + const agent = createAgent( + { + model: "claude_sonnet_4_6", + tools: { getWeather: { description: "weather", parameters: { type: "object" }, execute } }, + maxSteps: 4, + }, + openAICompatibleProvider(transport) + ); + const result = await agent.run({ prompt: "weather in Haifa?" }); + + expect(execute).toHaveBeenCalledWith({ city: "Haifa" }); + expect(result.text).toBe("It's sunny in Haifa."); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].toolResults[0]).toMatchObject({ toolCallId: "call_1", toolName: "getWeather" }); + // second request included the tool result message + const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body); + const toolMsg = secondBody.messages.find((m: any) => m.role === "tool"); + expect(toolMsg.tool_call_id).toBe("call_1"); + expect(JSON.parse(toolMsg.content)).toEqual({ city: "Haifa", condition: "sunny" }); + }); + + test("should feed a throwing tool's error back to the model instead of aborting", async () => { + fetchMock + .mockResolvedValueOnce( + completion({ toolCalls: [{ id: "c1", name: "boom", arguments: "{}" }], finish: "tool_calls" }) + ) + .mockResolvedValueOnce(completion({ content: "recovered" })); + const transport = createGatewayTransport(config); + const agent = createAgent( + { + model: "m", + tools: { boom: { description: "x", parameters: { type: "object" }, execute: async () => { throw new Error("kaboom"); } } }, + }, + openAICompatibleProvider(transport) + ); + const result = await agent.run({ prompt: "go" }); + expect(result.text).toBe("recovered"); + const toolMsg = JSON.parse(fetchMock.mock.calls[1][1].body).messages.find((m: any) => m.role === "tool"); + expect(toolMsg.content).toContain("Error: kaboom"); + }); + + test("should stop at maxSteps with finishReason 'max_steps'", async () => { + fetchMock.mockImplementation(() => + completion({ toolCalls: [{ id: "c", name: "t", arguments: "{}" }], finish: "tool_calls" }) + ); + const transport = createGatewayTransport(config); + const agent = createAgent( + { + model: "m", + tools: { t: { description: "x", parameters: { type: "object" }, execute: async () => "ok" } }, + maxSteps: 2, + }, + openAICompatibleProvider(transport) + ); + const result = await agent.run({ prompt: "loop" }); + expect(result.finishReason).toBe("max_steps"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("should accept a full messages array as run input", async () => { + fetchMock.mockResolvedValue(completion({ content: "ok" })); + const transport = createGatewayTransport(config); + const agent = createAgent({ model: "m" }, openAICompatibleProvider(transport)); + await agent.run({ messages: [{ role: "user", content: "a" }] }); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.messages).toEqual([{ role: "user", content: "a" }]); + }); + + test("history-replay via run({ messages }): system+tool history preserved faithfully", async () => { + fetchMock.mockResolvedValue(completion({ content: "28°C" })); + const transport = createGatewayTransport(config); + const agent = createAgent({ model: "m" }, openAICompatibleProvider(transport)); + await agent.run({ + messages: [ + { role: "system", content: "You are a pirate." }, + { role: "user", content: "weather in Haifa?" }, + { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "getWeather", arguments: '{"city":"Haifa"}' } }] }, + { role: "tool", tool_call_id: "c1", content: '{"tempC":28}' }, + { role: "user", content: "and tomorrow?" }, + ], + }); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + // System message stays as system, not flattened to user + expect(body.messages[0]).toEqual({ role: "system", content: "You are a pirate." }); + expect(body.messages[1]).toEqual({ role: "user", content: "weather in Haifa?" }); + // Assistant tool_calls re-serialized with arguments as JSON string + const asst = body.messages[2]; + expect(asst.role).toBe("assistant"); + expect(asst.tool_calls[0].function.arguments).toBe('{"city":"Haifa"}'); + // Tool result keyed by tool_call_id, not flattened to user + expect(body.messages[3]).toEqual({ role: "tool", tool_call_id: "c1", content: '{"tempC":28}' }); + expect(body.messages[4]).toEqual({ role: "user", content: "and tomorrow?" }); + expect(body.messages).toHaveLength(5); + }); + + test("create({system}).run({prompt}) sends leading system message then user", async () => { + fetchMock.mockResolvedValue(completion({ content: "aye" })); + const transport = createGatewayTransport(config); + const agent = createAgent({ model: "m", system: "You are a pirate." }, openAICompatibleProvider(transport)); + await agent.run({ prompt: "hello" }); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.messages[0]).toEqual({ role: "system", content: "You are a pirate." }); + expect(body.messages[1]).toEqual({ role: "user", content: "hello" }); + expect(body.messages).toHaveLength(2); + }); + + test("totalUsage sums usage across all model calls; steps[0].usage equals first call's mapped usage", async () => { + const firstUsage = { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, base44_credits: 2 }; + const secondUsage = { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, base44_credits: 2 }; + fetchMock + .mockResolvedValueOnce( + completion({ toolCalls: [{ id: "c1", name: "t", arguments: "{}" }], finish: "tool_calls", usage: firstUsage }) + ) + .mockResolvedValueOnce(completion({ content: "done", usage: secondUsage })); + + const transport = createGatewayTransport(config); + const agent = createAgent( + { model: "m", tools: { t: { description: "x", parameters: { type: "object" }, execute: async () => "r" } } }, + openAICompatibleProvider(transport) + ); + const result = await agent.run({ prompt: "go" }); + + expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 5, totalTokens: 15, credits: 2 }); + expect(result.totalUsage).toEqual({ inputTokens: 20, outputTokens: 10, totalTokens: 30, credits: 4 }); + expect(result.steps[0].usage).toEqual({ inputTokens: 10, outputTokens: 5, totalTokens: 15, credits: 2 }); + }); + + test("should execute parallel tool calls (two tool_calls in one completion) and include both role:tool messages in the next request", async () => { + fetchMock + .mockResolvedValueOnce( + completion({ + toolCalls: [ + { id: "c1", name: "toolA", arguments: '{"x":1}' }, + { id: "c2", name: "toolB", arguments: '{"y":2}' }, + ], + finish: "tool_calls", + }) + ) + .mockResolvedValueOnce(completion({ content: "all done" })); + + const executeA = vi.fn(async () => "resultA"); + const executeB = vi.fn(async () => "resultB"); + const transport = createGatewayTransport(config); + const agent = createAgent( + { + model: "m", + tools: { + toolA: { description: "a", parameters: { type: "object" }, execute: executeA }, + toolB: { description: "b", parameters: { type: "object" }, execute: executeB }, + }, + }, + openAICompatibleProvider(transport) + ); + const result = await agent.run({ prompt: "go" }); + + expect(executeA).toHaveBeenCalledWith({ x: 1 }); + expect(executeB).toHaveBeenCalledWith({ y: 2 }); + expect(result.text).toBe("all done"); + + const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body); + const toolMsgs = secondBody.messages.filter((m: any) => m.role === "tool"); + expect(toolMsgs).toHaveLength(2); + expect(toolMsgs.find((m: any) => m.tool_call_id === "c1")).toBeDefined(); + expect(toolMsgs.find((m: any) => m.tool_call_id === "c2")).toBeDefined(); + }); + + test("should feed back 'Error: tool \"\" is not available.' when model calls an unknown tool", async () => { + fetchMock + .mockResolvedValueOnce( + completion({ toolCalls: [{ id: "c1", name: "unknownTool", arguments: "{}" }], finish: "tool_calls" }) + ) + .mockResolvedValueOnce(completion({ content: "sorry" })); + + const transport = createGatewayTransport(config); + const agent = createAgent({ model: "m", tools: {} }, openAICompatibleProvider(transport)); + await agent.run({ prompt: "call missing tool" }); + + const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body); + const toolMsg = secondBody.messages.find((m: any) => m.role === "tool"); + expect(toolMsg.content).toBe('Error: tool "unknownTool" is not available.'); + }); + + test("should pass string tool result through as-is (not JSON-quoted)", async () => { + fetchMock + .mockResolvedValueOnce( + completion({ toolCalls: [{ id: "c1", name: "tempTool", arguments: "{}" }], finish: "tool_calls" }) + ) + .mockResolvedValueOnce(completion({ content: "done" })); + + const transport = createGatewayTransport(config); + const agent = createAgent( + { + model: "m", + tools: { tempTool: { description: "t", parameters: { type: "object" }, execute: async () => "hot" } }, + }, + openAICompatibleProvider(transport) + ); + await agent.run({ prompt: "go" }); + + const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body); + const toolMsg = secondBody.messages.find((m: any) => m.role === "tool"); + expect(toolMsg.content).toBe("hot"); + }); + + test("should reject immediately when an already-aborted AbortSignal is passed", async () => { + const controller = new AbortController(); + controller.abort(); + + fetchMock.mockImplementation((_url: string, init: RequestInit) => { + if (init?.signal?.aborted) { + return Promise.reject(new DOMException("The operation was aborted.", "AbortError")); + } + return Promise.resolve(completion({ content: "ok" })); + }); + + const transport = createGatewayTransport(config); + const agent = createAgent({ model: "m" }, openAICompatibleProvider(transport)); + await expect(agent.run({ prompt: "hi" }, { abortSignal: controller.signal })).rejects.toThrow(); + }); + + test("getToken late-binding: gateway reads getToken() at call time, not construction time", async () => { + let currentToken = "v1"; + const transport = createGatewayTransport({ + serverUrl: "https://app-z.base44.app", + getToken: () => currentToken, + }); + const provider = openAICompatibleProvider(transport); + const agent = createAgent({ model: "m" }, provider); + + fetchMock.mockResolvedValue(completion({ content: "ok" })); + + await agent.run({ prompt: "first" }); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer v1"); + + currentToken = "v2"; + await agent.run({ prompt: "second" }); + expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe("Bearer v2"); + }); + + test("no-usage response: totalUsage fields remain numeric (not NaN) after loop", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: "cmpl", + choices: [{ index: 0, message: { role: "assistant", content: "hi" }, finish_reason: "stop" }], + // no usage field + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + const transport = createGatewayTransport(config); + const agent = createAgent({ model: "m" }, openAICompatibleProvider(transport)); + const result = await agent.run({ prompt: "hello" }); + // sumUsage defaults undefined to 0, so totalUsage should be numeric + expect(typeof result.totalUsage.inputTokens).toBe("number"); + expect(typeof result.totalUsage.outputTokens).toBe("number"); + expect(isNaN(result.totalUsage.inputTokens!)).toBe(false); + expect(isNaN(result.totalUsage.outputTokens!)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Public package exports +// --------------------------------------------------------------------------- + +describe("Public package exports", () => { + test("should export tool from the package root", () => { + expect(typeof sdk.tool).toBe("function"); + }); +}); + +// --------------------------------------------------------------------------- +// base44.agents.create — client wiring +// --------------------------------------------------------------------------- + +describe("base44.agents.create — client wiring", () => { + let fetchMock: ReturnType; + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue(completion({ content: "agent-ok" })); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + test("should hit the gateway with the user token", async () => { + const base44 = createClient({ serverUrl: "https://app-y.base44.app", appId: "app-y", token: "user-tok-2" }); + const agent = base44.agents.create({ model: "gpt_5_mini" }); + const result = await agent.run({ prompt: "hello" }); + + expect(result.text).toBe("agent-ok"); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://app-y.base44.app/api/ai/unified/v1/chat/completions"); + expect(init.headers.Authorization).toBe("Bearer user-tok-2"); + }); + + test("should hit the gateway with the service token via asServiceRole", async () => { + const base44 = createClient({ + serverUrl: "https://app-y.base44.app", + appId: "app-y", + token: "user-tok-2", + serviceToken: "svc-tok-2", + }); + const agent = base44.asServiceRole.agents.create({ model: "gpt_5_mini" }); + await agent.run({ prompt: "hello" }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://app-y.base44.app/api/ai/unified/v1/chat/completions"); + expect(init.headers.Authorization).toBe("Bearer svc-tok-2"); + }); + + test("should run a full tool-calling loop through the client", async () => { + fetchMock + .mockResolvedValueOnce( + completion({ toolCalls: [{ id: "call_2", name: "ping", arguments: '{"msg":"test"}' }], finish: "tool_calls" }) + ) + .mockResolvedValueOnce(completion({ content: "pong" })); + + const execute = vi.fn(async ({ msg }: { msg: string }) => `pong: ${msg}`); + const base44 = createClient({ serverUrl: "https://app-y.base44.app", appId: "app-y", token: "user-tok-2" }); + const agent = base44.agents.create({ + model: "claude_sonnet_4_6", + tools: { ping: { description: "ping tool", parameters: { type: "object" }, execute } }, + }); + const result = await agent.run({ prompt: "ping me" }); + + expect(execute).toHaveBeenCalledWith({ msg: "test" }); + expect(result.text).toBe("pong"); + expect(result.steps).toHaveLength(1); + }); +}); diff --git a/tests/unit/agents-provider.test.ts b/tests/unit/agents-provider.test.ts new file mode 100644 index 00000000..575a00f2 --- /dev/null +++ b/tests/unit/agents-provider.test.ts @@ -0,0 +1,147 @@ +import { describe, test, expect, afterEach, vi } from "vitest"; +import { openAICompatibleProvider } from "../../src/modules/agents/providers/openai-compatible.ts"; + +const transportFor = (body: object) => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }) + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +}; + +describe("openAICompatibleProvider adapter", () => { + afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); + + // build a transport bound to the gateway + const makeModel = async () => { + const { createGatewayTransport } = await import("../../src/modules/agents/gateway.ts"); + return openAICompatibleProvider(createGatewayTransport({ serverUrl: "https://a.base44.app", getToken: () => "t" })); + }; + + test("system role message is serialized as a leading OpenAI system message", async () => { + const fetchMock = transportFor({ choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: {} }); + const model = await makeModel(); + await model.generate({ + model: "gpt_5_mini", + messages: [ + { role: "system", content: "Be terse." }, + { role: "user", content: "hi" }, + ], + }); + const sent = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sent.messages).toEqual([ + { role: "system", content: "Be terse." }, + { role: "user", content: "hi" }, + ]); + }); + + test("parses tool_calls into ModelToolCall with PARSED object args and forces finishReason 'tool-calls'", async () => { + transportFor({ + choices: [{ + message: { role: "assistant", content: null, tool_calls: [ + { id: "c1", type: "function", function: { name: "getWeather", arguments: '{"city":"Haifa"}' } }, + ] }, + finish_reason: "tool_calls", + }], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5, base44_credits: 1 }, + }); + const model = await makeModel(); + const r = await model.generate({ model: "m", messages: [{ role: "user", content: "weather?" }] }); + expect(r.toolCalls).toEqual([{ id: "c1", name: "getWeather", args: { city: "Haifa" } }]); + expect(r.finishReason).toBe("tool-calls"); + expect(r.usage).toEqual({ inputTokens: 3, outputTokens: 2, totalTokens: 5, credits: 1 }); + expect(r.text).toBe(""); + }); + + test("normalizes finish_reason and serializes a tool result message back to OpenAI shape", async () => { + const fetchMock = transportFor({ choices: [{ message: { role: "assistant", content: "done" }, finish_reason: "length" }], usage: {} }); + const model = await makeModel(); + const r = await model.generate({ + model: "m", + messages: [ + { role: "user", content: "go" }, + { role: "assistant", toolCalls: [{ id: "c1", name: "t", args: { x: 1 } }] }, + { role: "tool", toolCallId: "c1", toolName: "t", result: '{"ok":true}' }, + ], + }); + expect(r.finishReason).toBe("length"); + const sent = JSON.parse(fetchMock.mock.calls[0][1].body); + // assistant tool call re-serialized with arguments as a JSON STRING + const asst = sent.messages.find((m: any) => m.role === "assistant"); + expect(asst.tool_calls[0]).toEqual({ id: "c1", type: "function", function: { name: "t", arguments: '{"x":1}' } }); + // tool result keyed by tool_call_id + const toolMsg = sent.messages.find((m: any) => m.role === "tool"); + expect(toolMsg).toEqual({ role: "tool", tool_call_id: "c1", content: '{"ok":true}' }); + }); + + test("history-replay: full system+user+assistant(tool_calls)+tool+user round-trip preserves all roles faithfully", async () => { + const fetchMock = transportFor({ choices: [{ message: { role: "assistant", content: "sunny" }, finish_reason: "stop" }], usage: {} }); + const model = await makeModel(); + await model.generate({ + model: "m", + messages: [ + { role: "system", content: "You are a pirate." }, + { role: "user", content: "weather in Haifa?" }, + { role: "assistant", content: undefined, toolCalls: [{ id: "c1", name: "getWeather", args: { city: "Haifa" } }] }, + { role: "tool", toolCallId: "c1", result: '{"tempC":28}' }, + { role: "user", content: "and tomorrow?" }, + ], + }); + const sent = JSON.parse(fetchMock.mock.calls[0][1].body); + // Leading system message preserved + expect(sent.messages[0]).toEqual({ role: "system", content: "You are a pirate." }); + // User message preserved + expect(sent.messages[1]).toEqual({ role: "user", content: "weather in Haifa?" }); + // Assistant with tool_calls — arguments serialized as JSON string + const asst = sent.messages[2]; + expect(asst.role).toBe("assistant"); + expect(asst.tool_calls[0].function.arguments).toBe('{"city":"Haifa"}'); + // Tool result keyed by tool_call_id — NOT flattened to user + const toolMsg = sent.messages[3]; + expect(toolMsg).toEqual({ role: "tool", tool_call_id: "c1", content: '{"tempC":28}' }); + // Final user preserved + expect(sent.messages[4]).toEqual({ role: "user", content: "and tomorrow?" }); + expect(sent.messages).toHaveLength(5); + }); + + test("param whitelist: temperature and responseFormat are sent; max_tokens/top_p/seed/stop are never sent; response_format has strict:true", async () => { + const fetchMock = transportFor({ + choices: [{ message: { role: "assistant", content: "done" }, finish_reason: "stop" }], + usage: {}, + }); + const model = await makeModel(); + await model.generate({ + model: "gpt_5_mini", + messages: [{ role: "user", content: "hi" }], + temperature: 0.5, + responseFormat: { type: "object", properties: { a: { type: "string" } } }, + }); + const sent = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sent.temperature).toBe(0.5); + expect(sent.response_format).toEqual({ + type: "json_schema", + json_schema: { + name: "response", + schema: { type: "object", properties: { a: { type: "string" } } }, + strict: true, + }, + }); + expect(sent).not.toHaveProperty("max_tokens"); + expect(sent).not.toHaveProperty("top_p"); + expect(sent).not.toHaveProperty("seed"); + expect(sent).not.toHaveProperty("stop"); + }); + + test("no-usage response: result.usage fields are undefined (not NaN)", async () => { + transportFor({ + choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + // no usage field at all + }); + const model = await makeModel(); + const r = await model.generate({ model: "m", messages: [{ role: "user", content: "hi" }] }); + expect(r.usage.inputTokens).toBeUndefined(); + expect(r.usage.outputTokens).toBeUndefined(); + expect(r.usage.totalTokens).toBeUndefined(); + expect(r.usage.credits).toBeUndefined(); + }); +}); diff --git a/tests/unit/as-tool.test.ts b/tests/unit/as-tool.test.ts new file mode 100644 index 00000000..a1e121ec --- /dev/null +++ b/tests/unit/as-tool.test.ts @@ -0,0 +1,87 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { createClient } from "../../src/index.ts"; + +const opts = { serverUrl: "https://a.base44.app", appId: "a", token: "t" }; + +function reply(content: string) { + return new Response( + JSON.stringify({ choices: [{ message: { role: "assistant", content }, finish_reason: "stop" }], usage: {} }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); +} + +// --------------------------------------------------------------------------- +// functions.asTool +// --------------------------------------------------------------------------- + +describe("functions.asTool", () => { + let fetchMock: ReturnType; + beforeEach(() => { fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); }); + afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); + + test("should wrap invoke(name, args) with a supplied description and parameters", async () => { + // functions module uses axios, not fetch — mock axios post via the function endpoint. + const nock = (await import("nock")).default; + const scope = nock("https://a.base44.app") + .post("/api/apps/a/functions/sendOrderEmail", { orderId: "o1" }) + .reply(200, { sent: true }); + + const base44 = createClient(opts); + const t = base44.functions.asTool("sendOrderEmail", { + description: "Email the customer an update.", + parameters: { type: "object", properties: { orderId: { type: "string" } }, required: ["orderId"] }, + }); + + expect(t.description).toBe("Email the customer an update."); + expect(t.parameters).toEqual({ type: "object", properties: { orderId: { type: "string" } }, required: ["orderId"] }); + const out = await t.execute({ orderId: "o1" }); + expect(out).toEqual({ sent: true }); + scope.done(); + }); + + test("should default parameters to an open object when omitted", () => { + const base44 = createClient(opts); + const t = base44.functions.asTool("anyFn", { description: "d" }); + expect(t.parameters).toEqual({ type: "object", properties: {}, additionalProperties: true }); + }); + + test("should propagate errors when the function endpoint returns 500", async () => { + const nock = (await import("nock")).default; + nock("https://a.base44.app") + .post("/api/apps/a/functions/crashingFn") + .reply(500, { error: "Internal Server Error" }); + + const base44 = createClient(opts); + const t = base44.functions.asTool("crashingFn", { description: "a fn that crashes" }); + await expect(t.execute({})).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Agent.asTool +// --------------------------------------------------------------------------- + +describe("Agent.asTool", () => { + let fetchMock: ReturnType; + beforeEach(() => { fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); }); + afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); + + test("should produce a prompt-only tool that runs the sub-agent and returns its text", async () => { + const base44 = createClient(opts); + const sub = base44.agents.create({ model: "gpt_5_mini", system: "weather bot" }); + const t = sub.asTool({ name: "weather", description: "Get the weather for a city." }); + + expect(t.description).toBe("Get the weather for a city."); + expect(t.parameters).toEqual({ + type: "object", + properties: { prompt: { type: "string", description: "What to ask the sub-agent." } }, + required: ["prompt"], + }); + + fetchMock.mockResolvedValue(reply("Sunny in Haifa.")); + const out = await t.execute({ prompt: "weather in Haifa" }); + expect(out).toBe("Sunny in Haifa."); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.messages).toContainEqual({ role: "user", content: "weather in Haifa" }); + }); +}); diff --git a/tests/unit/entities-as-tool.test.ts b/tests/unit/entities-as-tool.test.ts new file mode 100644 index 00000000..657ea6f8 --- /dev/null +++ b/tests/unit/entities-as-tool.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import nock from "nock"; +import { createClient } from "../../src/index.ts"; + +describe("entities..asTool", () => { + let base44: ReturnType; + const appId = "test-app-id"; + const serverUrl = "https://api.base44.com"; + + beforeEach(() => { + base44 = createClient({ serverUrl, appId, token: "t" }); + nock.disableNetConnect(); + }); + afterEach(() => { nock.cleanAll(); nock.enableNetConnect(); vi.clearAllMocks(); }); + + test("default is read-only: only read_ is produced", () => { + const tools = base44.entities.Order.asTool(); + expect(Object.keys(tools)).toEqual(["read_Order"]); + }); + + test("operations select which per-op tools are produced (names match hosted)", () => { + const tools = base44.entities.Order.asTool({ operations: ["read", "create", "update", "delete"] }); + expect(Object.keys(tools).sort()).toEqual(["create_Order", "delete_Order", "read_Order", "update_Order"]); + }); + + test("read tool: description carries Mongo instructions; FilterParams shape", () => { + const { read_Order } = base44.entities.Order.asTool({ operations: ["read"] }); + expect(read_Order.description).toMatch(/^Read Order entities\./); + expect(read_Order.description).toMatch(/MongoDB query syntax|Mongo/i); + const props = (read_Order.parameters as any).properties; + expect(Object.keys(props).sort()).toEqual(["fields", "limit", "query", "skip", "sort"]); + }); + + test("create/update/delete descriptions + required id match hosted format", () => { + const t = base44.entities.Order.asTool({ operations: ["create", "update", "delete"] }); + expect(t.create_Order.description).toBe("Create a new Order entity"); + expect(t.update_Order.description).toBe("Update an existing Order entity"); + expect(t.delete_Order.description).toBe("Delete an existing Order entity"); + expect((t.update_Order.parameters as any).required).toContain("id"); + expect((t.delete_Order.parameters as any).required).toEqual(["id"]); + }); + + test("read_Order.execute -> entity filter endpoint", async () => { + const scope = nock(serverUrl) + .get(`/api/apps/${appId}/entities/Order`) + .query(true) + .reply(200, [{ id: "1", status: "open" }]); + const { read_Order } = base44.entities.Order.asTool({ operations: ["read"] }); + const out = await read_Order.execute({ query: { status: "open" }, limit: 5 }); + expect(out).toEqual([{ id: "1", status: "open" }]); + scope.done(); + }); + + test("create_Order.execute -> POST; update -> PUT/:id (id stripped from body); delete -> DELETE/:id", async () => { + const created = nock(serverUrl).post(`/api/apps/${appId}/entities/Order`, { status: "open" }).reply(200, { id: "9", status: "open" }); + const t = base44.entities.Order.asTool({ operations: ["create", "update", "delete"] }); + expect(await t.create_Order.execute({ status: "open" })).toEqual({ id: "9", status: "open" }); + created.done(); + + const updated = nock(serverUrl).put(`/api/apps/${appId}/entities/Order/9`, { status: "shipped" }).reply(200, { id: "9", status: "shipped" }); + expect(await t.update_Order.execute({ id: "9", status: "shipped" })).toEqual({ id: "9", status: "shipped" }); + updated.done(); + + const deleted = nock(serverUrl).delete(`/api/apps/${appId}/entities/Order/9`).reply(200, { deleted: true }); + await t.delete_Order.execute({ id: "9" }); + deleted.done(); + }); +});