diff --git a/src/client.ts b/src/client.ts index a028f33..9398016 100644 --- a/src/client.ts +++ b/src/client.ts @@ -10,6 +10,7 @@ import { import { getAccessToken } from "./utils/auth-utils.js"; import { createFunctionsModule } from "./modules/functions.js"; import { createAgentsModule } from "./modules/agents.js"; +import { createDynamicAgentsModule } from "./modules/dynamic-agents.js"; import { createAppLogsModule } from "./modules/app-logs.js"; import { createUsersModule } from "./modules/users.js"; import { RoomsSocket, RoomsSocketConfig } from "./utils/socket-utils.js"; @@ -190,6 +191,10 @@ export function createClient(config: CreateClientConfig): Base44Client { serverUrl, token, }), + dynamicAgents: createDynamicAgentsModule({ + serverUrl, + getToken: () => token || getAccessToken() || undefined, + }), appLogs: createAppLogsModule(axiosClient, appId), users: createUsersModule(axiosClient, appId), analytics: createAnalyticsModule({ @@ -233,6 +238,10 @@ export function createClient(config: CreateClientConfig): Base44Client { serverUrl, token, }), + dynamicAgents: createDynamicAgentsModule({ + serverUrl, + getToken: () => serviceToken, + }), appLogs: createAppLogsModule(serviceRoleAxiosClient, appId), cleanup: () => { if (socket) { diff --git a/src/client.types.ts b/src/client.types.ts index 6b4c9c5..f87e560 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -10,6 +10,7 @@ import type { FunctionsModule } from "./modules/functions.types.js"; import type { AgentsModule } from "./modules/agents.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; +import type { DynamicAgentsModule } from "./modules/dynamic-agents.types.js"; /** * Options for creating a Base44 client. @@ -91,6 +92,8 @@ export interface Base44Client { analytics: AnalyticsModule; /** {@link AppLogsModule | App logs module} for tracking app usage. */ appLogs: AppLogsModule; + /** {@link DynamicAgentsModule | Dynamic agents module} for code-defined AI agents and tool loops. */ + dynamicAgents: DynamicAgentsModule; /** {@link AuthModule | Auth module} for user authentication and management. */ auth: AuthModule; /** {@link UserConnectorsModule | Connectors module} for app-user OAuth flows. */ @@ -131,6 +134,8 @@ export interface Base44Client { agents: AgentsModule; /** {@link AppLogsModule | App logs module} with elevated permissions. */ appLogs: AppLogsModule; + /** {@link DynamicAgentsModule | Dynamic agents module} with elevated permissions. */ + dynamicAgents: DynamicAgentsModule; /** {@link ConnectorsModule | Connectors module} for OAuth token retrieval. */ connectors: ConnectorsModule; /** {@link EntitiesModule | Entities module} with elevated permissions. */ diff --git a/src/index.ts b/src/index.ts index bc531d8..d2935d0 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/dynamic-agents.js"; export { createClient, @@ -21,6 +22,7 @@ export { saveAccessToken, removeAccessToken, getLoginUrl, + tool, }; export type { @@ -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, + DynamicAgentsModule, +} from "./modules/dynamic-agents.types.js"; + // Auth utils types export type { GetAccessTokenOptions, diff --git a/src/modules/ai-gateway.ts b/src/modules/ai-gateway.ts new file mode 100644 index 0000000..29a44b2 --- /dev/null +++ b/src/modules/ai-gateway.ts @@ -0,0 +1,58 @@ +import { Base44Error } from "../utils/axios-client.js"; +import type { DynamicAgentsModuleConfig } from "./dynamic-agents.types.js"; + +/** + * Resolves the AI Gateway connection from a client config. + * @internal + */ +export function resolveConnection(config: DynamicAgentsModuleConfig): { + 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. Shaped so a streaming `.stream()` method can be added + * later without changing callers of `.complete()`. + * @internal + */ +export function createGatewayTransport(config: DynamicAgentsModuleConfig) { + return { + async complete( + body: Record, + opts: { signal?: AbortSignal } = {} + ): Promise { + const { baseURL, apiKey } = resolveConnection(config); + const res = await fetch(`${baseURL}/chat/completions`, { + 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/dynamic-agents.ts b/src/modules/dynamic-agents.ts new file mode 100644 index 0000000..d16e5c9 --- /dev/null +++ b/src/modules/dynamic-agents.ts @@ -0,0 +1,204 @@ +import { createGatewayTransport } from "./ai-gateway.js"; +import type { + Agent, + AgentConfig, + ChatMessage, + DynamicAgentsModule, + DynamicAgentsModuleConfig, + RunInput, + RunOptions, + RunResult, + Step, + Tool, +} from "./dynamic-agents.types.js"; + +/** + * Defines a tool an agent can call. + * + * @example + * ```typescript + * const getWeather = tool({ + * description: "Get the current weather for a city.", + * parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, + * execute: async ({ city }) => ({ city, tempC: 28 }), + * }); + * ``` + */ +export function tool(t: Tool): Tool { + return t; +} + +/** + * Maps a `{ name: Tool }` map to the OpenAI `tools[]` array. Returns `undefined` + * when empty so the param is omitted from the request body. + * @internal + */ +export function serializeTools(tools?: Record): any[] | undefined { + if (!tools) return undefined; + const entries = Object.entries(tools); + if (entries.length === 0) return undefined; + return entries.map(([name, t]) => ({ + type: "function", + function: { name, description: t.description, parameters: t.parameters }, + })); +} + +/** + * Builds the gateway request body from config + messages using an explicit whitelist. + * Rejected params (max_tokens, stop, top_p, penalties, logit_bias, seed, n) can never + * appear because only the supported keys are ever written. + * @internal + */ +export function buildRequestBody( + config: AgentConfig, + messages: ChatMessage[] +): Record { + const body: Record = { + model: config.model, + messages, + }; + if (config.temperature !== undefined) body.temperature = config.temperature; + if (config.toolChoice !== undefined) body.tool_choice = config.toolChoice; + if (config.responseFormat !== undefined) { + body.response_format = { + type: "json_schema", + json_schema: { name: "response", schema: config.responseFormat, strict: true }, + }; + } + const tools = serializeTools(config.tools); + if (tools) body.tools = tools; + return body; +} + +const DEFAULT_MAX_STEPS = 8; + +function inputToMessages(input: RunInput): ChatMessage[] { + if ("messages" in input) return input.messages; + return [{ role: "user", content: input.prompt }]; +} + +function stringifyResult(out: unknown): string { + return typeof out === "string" ? out : JSON.stringify(out); +} + +function mapUsage(raw: any): RunResult["usage"] { + const u = (raw && raw.usage) || {}; + return { + promptTokens: u.prompt_tokens, + completionTokens: u.completion_tokens, + totalTokens: u.total_tokens, + credits: u.base44_credits, + }; +} + +/** + * Creates the `base44.dynamicAgents` module. + * @internal + */ +export function createDynamicAgentsModule( + config: DynamicAgentsModuleConfig +): DynamicAgentsModule { + const transport = createGatewayTransport(config); + + function create(agentConfig: AgentConfig): Agent { + const maxSteps = agentConfig.maxSteps ?? DEFAULT_MAX_STEPS; + const tools = agentConfig.tools; + + const agent: Agent = { + async run(input: RunInput, options: RunOptions = {}): Promise { + const messages: ChatMessage[] = []; + if (agentConfig.system) messages.push({ role: "system", content: agentConfig.system }); + messages.push(...inputToMessages(input)); + + const steps: Step[] = []; + let raw: any = null; + + for (let i = 0; i < maxSteps; i++) { + const body = buildRequestBody(agentConfig, messages); + raw = await transport.complete(body, { signal: options.abortSignal }); + + const choice = raw?.choices?.[0]; + const message = choice?.message ?? { role: "assistant", content: "" }; + messages.push(message); + + const toolCalls = message.tool_calls; + if (!toolCalls || toolCalls.length === 0) { + return { + text: message.content ?? "", + steps, + finishReason: choice?.finish_reason ?? "stop", + usage: mapUsage(raw), + raw, + }; + } + + const toolResults: Step["toolResults"] = []; + for (const call of toolCalls) { + const name = call.function?.name; + const t = tools?.[name]; + let args: unknown = {}; + try { + args = JSON.parse(call.function?.arguments || "{}"); + } catch { + args = {}; + } + let resultContent: string; + if (!t) { + resultContent = `Error: tool "${name}" is not available.`; + } else { + try { + resultContent = stringifyResult(await t.execute(args)); + } catch (e: any) { + resultContent = `Error: ${e?.message ?? String(e)}`; + } + } + messages.push({ role: "tool", tool_call_id: call.id, content: resultContent }); + toolResults.push({ toolCallId: call.id, toolName: name, args, result: resultContent }); + } + steps.push({ toolResults }); + } + + // maxSteps exhausted + const lastMessage = raw?.choices?.[0]?.message; + return { + text: lastMessage?.content ?? "", + steps, + finishReason: "max_steps", + usage: mapUsage(raw), + raw, + }; + }, + + 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; + } + + function run( + runConfig: AgentConfig & ({ prompt: string } | { messages: ChatMessage[] }), + options?: RunOptions + ): Promise { + if ("messages" in runConfig) { + const { messages, ...agentConfig } = runConfig as AgentConfig & { messages: ChatMessage[] }; + return create(agentConfig).run({ messages }, options); + } + const { prompt, ...agentConfig } = runConfig as AgentConfig & { prompt: string }; + return create(agentConfig).run({ prompt }, options); + } + + return { create, run }; +} diff --git a/src/modules/dynamic-agents.types.ts b/src/modules/dynamic-agents.types.ts new file mode 100644 index 0000000..5e6e1c6 --- /dev/null +++ b/src/modules/dynamic-agents.types.ts @@ -0,0 +1,130 @@ +// src/modules/dynamic-agents.types.ts + +/** + * 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; +} + +/** An OpenAI-shaped chat message used internally and 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 for a run. `credits` is the Base44 gateway's `base44_credits`. */ +export interface RunUsage { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + credits?: number; +} + +/** Result of {@linkcode Agent.run} / {@linkcode DynamicAgentsModule.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: `"stop"` (model finished), `"tool_calls"`, or `"max_steps"`. */ + finishReason: string; + /** Token and credit usage from the final completion. */ + usage: 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. */ +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 dynamic agent. */ +export interface AgentConfig { + /** Model alias (e.g. `"claude_sonnet_4_6"`, `"gpt_5_mini"`) or vendor id. */ + model: string; + /** System prompt. */ + system?: string; + /** Tools the agent may call, keyed by name. */ + tools?: Record; + /** Max loop iterations before stopping. Default `8`. */ + maxSteps?: number; + /** Sampling temperature. Omitted unless set. Note: GPT-5 models only accept `1`. */ + temperature?: number; + /** A JSON Schema to constrain output to structured JSON (`response_format: json_schema`). */ + responseFormat?: JSONSchema; + /** Controls whether/which tool the model must call. */ + toolChoice?: ToolChoice; +} + +/** A reusable dynamic agent. */ +export interface Agent { + /** Run the agent's tool-calling loop to completion. */ + run(input: RunInput, options?: RunOptions): Promise; + /** + * Turn this agent into a {@linkcode Tool} so another agent can call it as a sub-agent. + * (Implemented in Effort 2.) + */ + asTool(opts: { name?: string; description: string }): Tool; +} + +/** The `base44.dynamicAgents` module. */ +export interface DynamicAgentsModule { + /** Define a reusable agent. */ + create(config: AgentConfig): Agent; + /** One-shot: `create(config).run({ prompt })`. */ + run(config: AgentConfig & RunInput, options?: RunOptions): Promise; +} + +/** + * Configuration for the dynamic-agents module. + * + * Note: the gateway resolves the app by request Host, so no `appId` is needed here — + * `serverUrl` must be an app-resolving domain. + * @internal + */ +export interface DynamicAgentsModuleConfig { + serverUrl: string; + /** Returns the current bearer token at call time (thunk — never a captured string). */ + getToken: () => string | undefined; +} diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 1a72c1d..1e4432a 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 b4e8b30..9f2334f 100644 --- a/src/modules/functions.types.ts +++ b/src/modules/functions.types.ts @@ -92,6 +92,17 @@ 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`. + */ + asTool(name: FunctionName, opts: { description: string; parameters?: Record }): import("./dynamic-agents.types.js").Tool; + /** * Performs a direct HTTP request to a backend function path and returns the native `Response`. * diff --git a/tests/unit/as-tool.test.ts b/tests/unit/as-tool.test.ts new file mode 100644 index 0000000..b6e17c0 --- /dev/null +++ b/tests/unit/as-tool.test.ts @@ -0,0 +1,69 @@ +// tests/unit/as-tool.test.ts +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" } } + ); +} + +describe("functions.asTool", () => { + let fetchMock: ReturnType; + beforeEach(() => { fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); }); + afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); + + test("wraps 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("defaults 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 }); + }); +}); + +describe("Agent.asTool", () => { + let fetchMock: ReturnType; + beforeEach(() => { fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); }); + afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); + + test("produces a prompt-only tool that runs the sub-agent and returns its text", async () => { + const base44 = createClient(opts); + const sub = base44.dynamicAgents.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/dynamic-agents.test.ts b/tests/unit/dynamic-agents.test.ts new file mode 100644 index 0000000..96a47bd --- /dev/null +++ b/tests/unit/dynamic-agents.test.ts @@ -0,0 +1,295 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { resolveConnection, createGatewayTransport } from "../../src/modules/ai-gateway.ts"; +import { Base44Error } from "../../src/index.ts"; +import { tool, serializeTools, buildRequestBody, createDynamicAgentsModule } from "../../src/modules/dynamic-agents.ts"; + +const config = { + serverUrl: "https://app-1.base44.app", + getToken: () => "tok-123", +}; + +describe("ai-gateway transport", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + test("resolveConnection builds the gateway baseURL and apiKey", () => { + expect(resolveConnection(config)).toEqual({ + baseURL: "https://app-1.base44.app/api/ai/unified/v1", + apiKey: "tok-123", + }); + }); + + test("complete() POSTs to /chat/completions with bearer auth and returns 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.complete(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("complete() maps the 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.complete({ model: "m", messages: [] })).rejects.toMatchObject({ + name: "Base44Error", + status: 402, + message: "insufficient quota", + }); + await expect(transport.complete({ model: "m", messages: [] })).rejects.toBeInstanceOf(Base44Error); + }); +}); + +describe("tool() + serializeTools()", () => { + test("tool() returns its argument unchanged", () => { + const t = { description: "d", parameters: { type: "object" }, execute: () => 1 }; + expect(tool(t)).toBe(t); + }); + + test("serializeTools maps to OpenAI function-tool shape", () => { + const getWeather = { + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, + execute: async () => ({}), + }; + expect(serializeTools({ getWeather })).toEqual([ + { + type: "function", + function: { + name: "getWeather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, + }, + }, + ]); + }); + + test("serializeTools returns undefined when there are no tools", () => { + expect(serializeTools(undefined)).toBeUndefined(); + expect(serializeTools({})).toBeUndefined(); + }); +}); + +describe("buildRequestBody()", () => { + const messages = [{ role: "user" as const, content: "hi" }]; + + test("emits only model and messages by default (temperature omitted)", () => { + expect(buildRequestBody({ model: "gpt_5_mini" }, messages)).toEqual({ + model: "gpt_5_mini", + messages, + }); + }); + + test("includes temperature, tool_choice, response_format and tools when set", () => { + const body = buildRequestBody( + { + model: "claude_sonnet_4_6", + temperature: 0.3, + toolChoice: "auto", + responseFormat: { type: "object", properties: { a: { type: "string" } } }, + tools: { t: { description: "d", parameters: { type: "object" }, execute: () => 1 } }, + }, + messages + ); + expect(body.temperature).toBe(0.3); + expect(body.tool_choice).toBe("auto"); + expect(body.response_format).toEqual({ + type: "json_schema", + json_schema: { name: "response", schema: { type: "object", properties: { a: { type: "string" } } }, strict: true }, + }); + expect(Array.isArray(body.tools)).toBe(true); + }); + + test("never emits rejected params even if smuggled in via cast", () => { + const sneaky = { model: "m", max_tokens: 50, stop: ["x"], top_p: 0.5, seed: 1, n: 2 } as any; + const body = buildRequestBody(sneaky, messages); + for (const k of ["max_tokens", "max_completion_tokens", "stop", "top_p", "frequency_penalty", "presence_penalty", "logit_bias", "seed", "n"]) { + expect(body).not.toHaveProperty(k); + } + }); +}); + +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" } } + ); +} + +describe("agent loop", () => { + let fetchMock: ReturnType; + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + test("run() returns text, usage (incl. credits), and finishReason on a no-tool completion", async () => { + fetchMock.mockResolvedValue(completion({ content: "Hello there." })); + const mod = createDynamicAgentsModule(config); + const result = await mod.run({ model: "gpt_5_mini", system: "Be terse.", prompt: "Hi" }); + + expect(result.text).toBe("Hello there."); + expect(result.finishReason).toBe("stop"); + expect(result.usage).toEqual({ promptTokens: 10, completionTokens: 5, totalTokens: 15, credits: 2 }); + 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("create().run() executes a tool then continues 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 agent = createDynamicAgentsModule(config).create({ + model: "claude_sonnet_4_6", + tools: { getWeather: { description: "weather", parameters: { type: "object" }, execute } }, + maxSteps: 4, + }); + 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("a throwing tool feeds the 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 agent = createDynamicAgentsModule(config).create({ + model: "m", + tools: { boom: { description: "x", parameters: { type: "object" }, execute: async () => { throw new Error("kaboom"); } } }, + }); + 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("stops at maxSteps with finishReason 'max_steps'", async () => { + fetchMock.mockImplementation(() => + completion({ toolCalls: [{ id: "c", name: "t", arguments: "{}" }], finish: "tool_calls" }) + ); + const agent = createDynamicAgentsModule(config).create({ + model: "m", + tools: { t: { description: "x", parameters: { type: "object" }, execute: async () => "ok" } }, + maxSteps: 2, + }); + const result = await agent.run({ prompt: "loop" }); + expect(result.finishReason).toBe("max_steps"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("run() accepts a full messages array", async () => { + fetchMock.mockResolvedValue(completion({ content: "ok" })); + const mod = createDynamicAgentsModule(config); + await mod.run({ model: "m", messages: [{ role: "user", content: "a" }] }); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.messages).toEqual([{ role: "user", content: "a" }]); + }); +}); + +import { createClient } from "../../src/index.ts"; +import * as sdk from "../../src/index.ts"; + +describe("public exports", () => { + test("tool is exported from the package root", () => { + expect(typeof sdk.tool).toBe("function"); + }); +}); + +describe("client wiring", () => { + let fetchMock: ReturnType; + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue(completion({ content: "ok" })); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + test("base44.dynamicAgents.run hits the gateway with the user token", async () => { + const base44 = createClient({ serverUrl: "https://app-x.base44.app", appId: "app-x", token: "user-tok" }); + const result = await base44.dynamicAgents.run({ model: "gpt_5_mini", prompt: "hi" }); + expect(result.text).toBe("ok"); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://app-x.base44.app/api/ai/unified/v1/chat/completions"); + expect(init.headers.Authorization).toBe("Bearer user-tok"); + }); + + test("asServiceRole.dynamicAgents uses the service token", async () => { + const base44 = createClient({ + serverUrl: "https://app-x.base44.app", + appId: "app-x", + token: "user-tok", + serviceToken: "svc-tok", + }); + await base44.asServiceRole.dynamicAgents.run({ model: "m", prompt: "hi" }); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer svc-tok"); + }); +});