From 3d46dc58fc45ac406518d0b2098f49a9015798f3 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 11:29:53 +0300 Subject: [PATCH 01/29] feat(dynamic-agents): add types module --- src/modules/dynamic-agents.types.ts | 130 ++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/modules/dynamic-agents.types.ts diff --git a/src/modules/dynamic-agents.types.ts b/src/modules/dynamic-agents.types.ts new file mode 100644 index 0000000..ce002e1 --- /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 & { prompt: string }, 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; +} From dad3c396241cb50020cba1886944224a95a00b93 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 11:31:44 +0300 Subject: [PATCH 02/29] feat(dynamic-agents): add gateway transport --- src/modules/ai-gateway.ts | 58 ++++++++++++++++++++++++++ tests/unit/dynamic-agents.test.ts | 67 +++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/modules/ai-gateway.ts create mode 100644 tests/unit/dynamic-agents.test.ts 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/tests/unit/dynamic-agents.test.ts b/tests/unit/dynamic-agents.test.ts new file mode 100644 index 0000000..5a5b453 --- /dev/null +++ b/tests/unit/dynamic-agents.test.ts @@ -0,0 +1,67 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { resolveConnection, createGatewayTransport } from "../../src/modules/ai-gateway.ts"; +import { Base44Error } from "../../src/index.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); + }); +}); From ce8942183f1aab276da6fdc6c8a2c24bc8caa59b Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 11:34:14 +0300 Subject: [PATCH 03/29] feat(dynamic-agents): add tool() factory and serialization Implement tool() identity factory as a public API seam and serializeTools() to map tools to OpenAI function-tool format. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/modules/dynamic-agents.ts | 32 +++++++++++++++++++++++++++++++ tests/unit/dynamic-agents.test.ts | 31 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/modules/dynamic-agents.ts diff --git a/src/modules/dynamic-agents.ts b/src/modules/dynamic-agents.ts new file mode 100644 index 0000000..164d50a --- /dev/null +++ b/src/modules/dynamic-agents.ts @@ -0,0 +1,32 @@ +import type { 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 }, + })); +} diff --git a/tests/unit/dynamic-agents.test.ts b/tests/unit/dynamic-agents.test.ts index 5a5b453..283a177 100644 --- a/tests/unit/dynamic-agents.test.ts +++ b/tests/unit/dynamic-agents.test.ts @@ -1,6 +1,7 @@ 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 } from "../../src/modules/dynamic-agents.ts"; const config = { serverUrl: "https://app-1.base44.app", @@ -65,3 +66,33 @@ describe("ai-gateway transport", () => { 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(); + }); +}); From e1dc7405ecaddf2856f1195a1fb5642666da9133 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 11:36:19 +0300 Subject: [PATCH 04/29] feat(dynamic-agents): add request body builder with param whitelist --- src/modules/dynamic-agents.ts | 29 +++++++++++++++++++++- tests/unit/dynamic-agents.test.ts | 41 ++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/modules/dynamic-agents.ts b/src/modules/dynamic-agents.ts index 164d50a..86a4ea3 100644 --- a/src/modules/dynamic-agents.ts +++ b/src/modules/dynamic-agents.ts @@ -1,4 +1,4 @@ -import type { Tool } from "./dynamic-agents.types.js"; +import type { AgentConfig, ChatMessage, Tool } from "./dynamic-agents.types.js"; /** * Defines a tool an agent can call. @@ -30,3 +30,30 @@ export function serializeTools(tools?: Record): any[] | undefined 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; +} diff --git a/tests/unit/dynamic-agents.test.ts b/tests/unit/dynamic-agents.test.ts index 283a177..9c996a5 100644 --- a/tests/unit/dynamic-agents.test.ts +++ b/tests/unit/dynamic-agents.test.ts @@ -1,7 +1,7 @@ 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 } from "../../src/modules/dynamic-agents.ts"; +import { tool, serializeTools, buildRequestBody } from "../../src/modules/dynamic-agents.ts"; const config = { serverUrl: "https://app-1.base44.app", @@ -96,3 +96,42 @@ describe("tool() + serializeTools()", () => { 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); + } + }); +}); From 7ba9f1ea66cbaab8aed6c4e94e91595c061a1212 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 11:43:47 +0300 Subject: [PATCH 05/29] feat(dynamic-agents): add agent loop and create/run module --- src/modules/ai-gateway.ts | 2 +- src/modules/dynamic-agents.ts | 136 +++++++++++++++++++++++++++++- tests/unit/dynamic-agents.test.ts | 119 +++++++++++++++++++++++++- 3 files changed, 254 insertions(+), 3 deletions(-) diff --git a/src/modules/ai-gateway.ts b/src/modules/ai-gateway.ts index 29a44b2..bdec2cd 100644 --- a/src/modules/ai-gateway.ts +++ b/src/modules/ai-gateway.ts @@ -40,7 +40,7 @@ export function createGatewayTransport(config: DynamicAgentsModuleConfig) { signal: opts.signal, }); - const json = await res.json().catch(() => null); + const json = await res.clone().json().catch(() => null); if (!res.ok) { const err = (json && json.error) || {}; diff --git a/src/modules/dynamic-agents.ts b/src/modules/dynamic-agents.ts index 86a4ea3..a961e40 100644 --- a/src/modules/dynamic-agents.ts +++ b/src/modules/dynamic-agents.ts @@ -1,4 +1,16 @@ -import type { AgentConfig, ChatMessage, Tool } from "./dynamic-agents.types.js"; +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. @@ -57,3 +69,125 @@ export function buildRequestBody( 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(): never { + throw new Error("Agent.asTool() is implemented in Effort 2."); + }, + }; + + 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/tests/unit/dynamic-agents.test.ts b/tests/unit/dynamic-agents.test.ts index 9c996a5..b511df4 100644 --- a/tests/unit/dynamic-agents.test.ts +++ b/tests/unit/dynamic-agents.test.ts @@ -1,7 +1,7 @@ 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 } from "../../src/modules/dynamic-agents.ts"; +import { tool, serializeTools, buildRequestBody, createDynamicAgentsModule } from "../../src/modules/dynamic-agents.ts"; const config = { serverUrl: "https://app-1.base44.app", @@ -135,3 +135,120 @@ describe("buildRequestBody()", () => { } }); }); + +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.mockResolvedValue( + 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" }] } as any); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.messages).toEqual([{ role: "user", content: "a" }]); + }); +}); From e3e2dfc00843fe8cdf8ec6bb720e0ee6389416a1 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 11:49:16 +0300 Subject: [PATCH 06/29] fix(dynamic-agents): revert clone() in transport; align run() type with impl Co-Authored-By: Claude Sonnet 4.6 --- src/modules/ai-gateway.ts | 2 +- src/modules/dynamic-agents.types.ts | 2 +- tests/unit/dynamic-agents.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/ai-gateway.ts b/src/modules/ai-gateway.ts index bdec2cd..29a44b2 100644 --- a/src/modules/ai-gateway.ts +++ b/src/modules/ai-gateway.ts @@ -40,7 +40,7 @@ export function createGatewayTransport(config: DynamicAgentsModuleConfig) { signal: opts.signal, }); - const json = await res.clone().json().catch(() => null); + const json = await res.json().catch(() => null); if (!res.ok) { const err = (json && json.error) || {}; diff --git a/src/modules/dynamic-agents.types.ts b/src/modules/dynamic-agents.types.ts index ce002e1..5e6e1c6 100644 --- a/src/modules/dynamic-agents.types.ts +++ b/src/modules/dynamic-agents.types.ts @@ -113,7 +113,7 @@ export interface DynamicAgentsModule { /** Define a reusable agent. */ create(config: AgentConfig): Agent; /** One-shot: `create(config).run({ prompt })`. */ - run(config: AgentConfig & { prompt: string }, options?: RunOptions): Promise; + run(config: AgentConfig & RunInput, options?: RunOptions): Promise; } /** diff --git a/tests/unit/dynamic-agents.test.ts b/tests/unit/dynamic-agents.test.ts index b511df4..9adf6a4 100644 --- a/tests/unit/dynamic-agents.test.ts +++ b/tests/unit/dynamic-agents.test.ts @@ -231,7 +231,7 @@ describe("agent loop", () => { }); test("stops at maxSteps with finishReason 'max_steps'", async () => { - fetchMock.mockResolvedValue( + fetchMock.mockImplementation(() => completion({ toolCalls: [{ id: "c", name: "t", arguments: "{}" }], finish: "tool_calls" }) ); const agent = createDynamicAgentsModule(config).create({ @@ -247,7 +247,7 @@ describe("agent loop", () => { 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" }] } as any); + 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" }]); }); From 516796e4b6beff9c49f472209de0d1986ab32a44 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 11:51:15 +0300 Subject: [PATCH 07/29] feat(dynamic-agents): wire module into client (user + service role) --- src/client.ts | 9 ++++++++ tests/unit/dynamic-agents.test.ts | 34 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/client.ts b/src/client.ts index a028f33..75e49e3 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(), + }), 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/tests/unit/dynamic-agents.test.ts b/tests/unit/dynamic-agents.test.ts index 9adf6a4..71a049a 100644 --- a/tests/unit/dynamic-agents.test.ts +++ b/tests/unit/dynamic-agents.test.ts @@ -252,3 +252,37 @@ describe("agent loop", () => { expect(body.messages).toEqual([{ role: "user", content: "a" }]); }); }); + +import { createClient } from "../../src/index.ts"; + +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"); + }); +}); From 869ada42cbbe454ec85ed8056cc3ebc1d99e7752 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 11:53:21 +0300 Subject: [PATCH 08/29] fix(dynamic-agents): declare dynamicAgents on client type; fix getToken thunk type Co-Authored-By: Claude Sonnet 4.6 --- src/client.ts | 2 +- src/client.types.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 75e49e3..9398016 100644 --- a/src/client.ts +++ b/src/client.ts @@ -193,7 +193,7 @@ export function createClient(config: CreateClientConfig): Base44Client { }), dynamicAgents: createDynamicAgentsModule({ serverUrl, - getToken: () => token || getAccessToken(), + getToken: () => token || getAccessToken() || undefined, }), appLogs: createAppLogsModule(axiosClient, appId), users: createUsersModule(axiosClient, appId), 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. */ From 8c4f30745bec0854c7de702ea3755178013a436c Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 11:55:29 +0300 Subject: [PATCH 09/29] feat(dynamic-agents): export tool() and public types --- src/index.ts | 17 +++++++++++++++++ tests/unit/dynamic-agents.test.ts | 7 +++++++ 2 files changed, 24 insertions(+) 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/tests/unit/dynamic-agents.test.ts b/tests/unit/dynamic-agents.test.ts index 71a049a..96a47bd 100644 --- a/tests/unit/dynamic-agents.test.ts +++ b/tests/unit/dynamic-agents.test.ts @@ -254,6 +254,13 @@ describe("agent loop", () => { }); 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; From 14fdf1f75e8dddb040b0b119b79a3a17b4962d32 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 11:58:02 +0300 Subject: [PATCH 10/29] feat(dynamic-agents): implement Agent.asTool sub-agent tool --- src/modules/dynamic-agents.ts | 15 ++++++++++++-- tests/unit/as-tool.test.ts | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 tests/unit/as-tool.test.ts diff --git a/src/modules/dynamic-agents.ts b/src/modules/dynamic-agents.ts index a961e40..d16e5c9 100644 --- a/src/modules/dynamic-agents.ts +++ b/src/modules/dynamic-agents.ts @@ -169,8 +169,19 @@ export function createDynamicAgentsModule( }; }, - asTool(): never { - throw new Error("Agent.asTool() is implemented in Effort 2."); + 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; + }, + }; }, }; diff --git a/tests/unit/as-tool.test.ts b/tests/unit/as-tool.test.ts new file mode 100644 index 0000000..79d3921 --- /dev/null +++ b/tests/unit/as-tool.test.ts @@ -0,0 +1,37 @@ +// 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("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" }); + }); +}); From 7584305340a0578d9d65df90daaa6e878348955e Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 12:01:39 +0300 Subject: [PATCH 11/29] feat(dynamic-agents): add functions.asTool --- src/modules/functions.ts | 24 ++++++++++++++++++++---- src/modules/functions.types.ts | 11 +++++++++++ tests/unit/as-tool.test.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 1a72c1d..3b3e2ad 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,18 @@ 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 axiosResponse: any = await invoke(name, args ?? {}); + const body = axiosResponse?.data; + return body && typeof body === "object" && "data" in body ? body.data : body; + }, + }; + }, }; } 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 index 79d3921..dff613d 100644 --- a/tests/unit/as-tool.test.ts +++ b/tests/unit/as-tool.test.ts @@ -11,6 +11,38 @@ function reply(content: string) { ); } +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, { data: { 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); }); From 6efe0c0e520f76907dcd7a100e2941c985721010 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 13:08:00 +0300 Subject: [PATCH 12/29] fix(dynamic-agents): functions.asTool returns the function body directly (drop fragile data-unwrap) Co-Authored-By: Claude Sonnet 4.6 --- src/modules/functions.ts | 5 ++--- tests/unit/as-tool.test.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 3b3e2ad..1e4432a 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -112,9 +112,8 @@ export function createFunctionsModule( description: opts.description, parameters: opts.parameters ?? { type: "object", properties: {}, additionalProperties: true }, execute: async (args: Record) => { - const axiosResponse: any = await invoke(name, args ?? {}); - const body = axiosResponse?.data; - return body && typeof body === "object" && "data" in body ? body.data : body; + const res: any = await invoke(name, args ?? {}); + return res?.data; }, }; }, diff --git a/tests/unit/as-tool.test.ts b/tests/unit/as-tool.test.ts index dff613d..b6e17c0 100644 --- a/tests/unit/as-tool.test.ts +++ b/tests/unit/as-tool.test.ts @@ -21,7 +21,7 @@ describe("functions.asTool", () => { const nock = (await import("nock")).default; const scope = nock("https://a.base44.app") .post("/api/apps/a/functions/sendOrderEmail", { orderId: "o1" }) - .reply(200, { data: { sent: true } }); + .reply(200, { sent: true }); const base44 = createClient(opts); const t = base44.functions.asTool("sendOrderEmail", { From b9a90d55c62313d0d34dc8649066b03b9e745990 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 23 Jun 2026 20:38:52 +0300 Subject: [PATCH 13/29] refactor(agents): split tool.ts and agent-loop.ts out of dynamic-agents Co-Authored-By: Claude Sonnet 4.6 --- src/modules/agent-loop.ts | 185 ++++++++++++++++++++++++++ src/modules/dynamic-agents.ts | 207 +----------------------------- src/modules/tool.ts | 32 +++++ tests/unit/dynamic-agents.test.ts | 3 +- 4 files changed, 222 insertions(+), 205 deletions(-) create mode 100644 src/modules/agent-loop.ts create mode 100644 src/modules/tool.ts diff --git a/src/modules/agent-loop.ts b/src/modules/agent-loop.ts new file mode 100644 index 0000000..0a3f92e --- /dev/null +++ b/src/modules/agent-loop.ts @@ -0,0 +1,185 @@ +import type { + Agent, + AgentConfig, + ChatMessage, + DynamicAgentsModule, + DynamicAgentsModuleConfig, + RunInput, + RunOptions, + RunResult, + Step, + Tool, +} from "./dynamic-agents.types.js"; +import { createGatewayTransport } from "./ai-gateway.js"; +import { serializeTools } from "./tool.js"; + +/** + * 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 an Agent from a config and a gateway transport. + * @internal + */ +export function createAgent( + agentConfig: AgentConfig, + transport: { complete(body: Record, opts?: { signal?: AbortSignal }): Promise } +): 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; +} + +/** + * Creates the `base44.dynamicAgents` module. + * @internal + */ +export function createDynamicAgentsModule( + config: DynamicAgentsModuleConfig +): DynamicAgentsModule { + const transport = createGatewayTransport(config); + + function create(agentConfig: AgentConfig): Agent { + return createAgent(agentConfig, transport); + } + + 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.ts b/src/modules/dynamic-agents.ts index d16e5c9..54448f6 100644 --- a/src/modules/dynamic-agents.ts +++ b/src/modules/dynamic-agents.ts @@ -1,204 +1,3 @@ -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 }; -} +// Re-export shim — all implementations have moved to tool.ts and agent-loop.ts. +export { tool, serializeTools } from "./tool.js"; +export { createAgent, buildRequestBody, createDynamicAgentsModule } from "./agent-loop.js"; diff --git a/src/modules/tool.ts b/src/modules/tool.ts new file mode 100644 index 0000000..164d50a --- /dev/null +++ b/src/modules/tool.ts @@ -0,0 +1,32 @@ +import type { 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 }, + })); +} diff --git a/tests/unit/dynamic-agents.test.ts b/tests/unit/dynamic-agents.test.ts index 96a47bd..6c2c6b0 100644 --- a/tests/unit/dynamic-agents.test.ts +++ b/tests/unit/dynamic-agents.test.ts @@ -1,7 +1,8 @@ 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"; +import { tool, serializeTools } from "../../src/modules/tool.ts"; +import { buildRequestBody, createDynamicAgentsModule } from "../../src/modules/agent-loop.ts"; const config = { serverUrl: "https://app-1.base44.app", From c2c0505d18dee8b9b93391776fdc7bf504e9e66a Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 09:44:13 +0300 Subject: [PATCH 14/29] feat(agents): add base44.agents.create (code-defined agents) alongside dynamicAgents Co-Authored-By: Claude Sonnet 4.6 --- src/client.ts | 2 + src/modules/agents.ts | 8 ++ src/modules/agents.types.ts | 135 ++++++++++++++++++++++++++++ src/modules/dynamic-agents.types.ts | 126 ++++---------------------- tests/unit/dynamic-agents.test.ts | 58 ++++++++++++ 5 files changed, 219 insertions(+), 110 deletions(-) diff --git a/src/client.ts b/src/client.ts index 9398016..4899477 100644 --- a/src/client.ts +++ b/src/client.ts @@ -190,6 +190,7 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, serverUrl, token, + getToken: () => token || getAccessToken() || undefined, }), dynamicAgents: createDynamicAgentsModule({ serverUrl, @@ -237,6 +238,7 @@ export function createClient(config: CreateClientConfig): Base44Client { appId, serverUrl, token, + getToken: () => serviceToken, }), dynamicAgents: createDynamicAgentsModule({ serverUrl, diff --git a/src/modules/agents.ts b/src/modules/agents.ts index 015261f..4293aad 100644 --- a/src/modules/agents.ts +++ b/src/modules/agents.ts @@ -7,6 +7,8 @@ import { AgentsModuleConfig, CreateConversationParams, } from "./agents.types.js"; +import { createGatewayTransport } from "./ai-gateway.js"; +import { createAgent } from "./agent-loop.js"; export function createAgentsModule({ axios, @@ -14,7 +16,12 @@ export function createAgentsModule({ appId, serverUrl, token, + getToken, }: AgentsModuleConfig): AgentsModule { + const transport = createGatewayTransport({ + serverUrl: serverUrl ?? "", + getToken: getToken ?? (() => token), + }); const baseURL = `/apps/${appId}/agents`; // Track active conversations @@ -135,5 +142,6 @@ export function createAgentsModule({ subscribeToConversation, getWhatsAppConnectURL, getTelegramConnectURL, + create(config) { return createAgent(config, transport); }, }; } diff --git a/src/modules/agents.types.ts b/src/modules/agents.types.ts index 49918c0..2cf08eb 100644 --- a/src/modules/agents.types.ts +++ b/src/modules/agents.types.ts @@ -2,6 +2,117 @@ import { AxiosInstance } from "axios"; import { RoomsSocket } from "../utils/socket-utils.js"; import { ModelFilterParams } from "../types.js"; +// --------------------------------------------------------------------------- +// Code-agent types (moved from 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}. */ +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 code-defined 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 code-defined 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. + */ + 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 +285,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 +500,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 agent. + * + * @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/dynamic-agents.types.ts b/src/modules/dynamic-agents.types.ts index 5e6e1c6..344cdb1 100644 --- a/src/modules/dynamic-agents.types.ts +++ b/src/modules/dynamic-agents.types.ts @@ -1,119 +1,25 @@ // 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; -} +// Re-export code-agent types from agents.types.ts (transition shim — kept for A2 additive phase) +export type { + JSONSchema, + Tool, + ChatMessage, + Step, + RunUsage, + RunResult, + RunInput, + RunOptions, + ToolChoice, + AgentConfig, + Agent, +} from "./agents.types.js"; /** The `base44.dynamicAgents` module. */ export interface DynamicAgentsModule { /** Define a reusable agent. */ - create(config: AgentConfig): Agent; + create(config: import("./agents.types.js").AgentConfig): import("./agents.types.js").Agent; /** One-shot: `create(config).run({ prompt })`. */ - run(config: AgentConfig & RunInput, options?: RunOptions): Promise; + run(config: import("./agents.types.js").AgentConfig & import("./agents.types.js").RunInput, options?: import("./agents.types.js").RunOptions): Promise; } /** diff --git a/tests/unit/dynamic-agents.test.ts b/tests/unit/dynamic-agents.test.ts index 6c2c6b0..a19db43 100644 --- a/tests/unit/dynamic-agents.test.ts +++ b/tests/unit/dynamic-agents.test.ts @@ -294,3 +294,61 @@ describe("client wiring", () => { expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer svc-tok"); }); }); + +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("base44.agents.create().run() hits 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("asServiceRole.agents.create().run() hits the gateway with the service token", 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("base44.agents.create().run() runs a full tool-calling loop", 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); + }); +}); From a57ae271643e0ac4b75f21f94f5d705dd81beb6f Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 09:50:29 +0300 Subject: [PATCH 15/29] refactor(agents): remove dynamicAgents surface and module-level run(); single base44.agents namespace Co-Authored-By: Claude Sonnet 4.6 --- src/client.ts | 9 -- src/client.types.ts | 5 - src/index.ts | 5 +- src/modules/agent-loop.ts | 32 +------ src/modules/ai-gateway.ts | 12 ++- src/modules/dynamic-agents.ts | 3 - src/modules/dynamic-agents.types.ts | 36 -------- src/modules/functions.types.ts | 2 +- src/modules/tool.ts | 2 +- ...mic-agents.test.ts => agents-code.test.ts} | 91 ++++++++----------- tests/unit/as-tool.test.ts | 2 +- 11 files changed, 51 insertions(+), 148 deletions(-) delete mode 100644 src/modules/dynamic-agents.ts delete mode 100644 src/modules/dynamic-agents.types.ts rename tests/unit/{dynamic-agents.test.ts => agents-code.test.ts} (83%) diff --git a/src/client.ts b/src/client.ts index 4899477..27623ba 100644 --- a/src/client.ts +++ b/src/client.ts @@ -10,7 +10,6 @@ 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"; @@ -192,10 +191,6 @@ export function createClient(config: CreateClientConfig): Base44Client { token, getToken: () => token || getAccessToken() || undefined, }), - dynamicAgents: createDynamicAgentsModule({ - serverUrl, - getToken: () => token || getAccessToken() || undefined, - }), appLogs: createAppLogsModule(axiosClient, appId), users: createUsersModule(axiosClient, appId), analytics: createAnalyticsModule({ @@ -240,10 +235,6 @@ export function createClient(config: CreateClientConfig): Base44Client { token, getToken: () => serviceToken, }), - 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 f87e560..6b4c9c5 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -10,7 +10,6 @@ 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. @@ -92,8 +91,6 @@ 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. */ @@ -134,8 +131,6 @@ 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 d2935d0..fde8817 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import { removeAccessToken, getLoginUrl, } from "./utils/auth-utils.js"; -import { tool } from "./modules/dynamic-agents.js"; +import { tool } from "./modules/tool.js"; export { createClient, @@ -129,8 +129,7 @@ export type { ToolChoice, AgentConfig, Agent, - DynamicAgentsModule, -} from "./modules/dynamic-agents.types.js"; +} from "./modules/agents.types.js"; // Auth utils types export type { diff --git a/src/modules/agent-loop.ts b/src/modules/agent-loop.ts index 0a3f92e..d0d69ee 100644 --- a/src/modules/agent-loop.ts +++ b/src/modules/agent-loop.ts @@ -2,15 +2,12 @@ import type { Agent, AgentConfig, ChatMessage, - DynamicAgentsModule, - DynamicAgentsModuleConfig, RunInput, RunOptions, RunResult, Step, Tool, -} from "./dynamic-agents.types.js"; -import { createGatewayTransport } from "./ai-gateway.js"; +} from "./agents.types.js"; import { serializeTools } from "./tool.js"; /** @@ -156,30 +153,3 @@ export function createAgent( return agent; } -/** - * Creates the `base44.dynamicAgents` module. - * @internal - */ -export function createDynamicAgentsModule( - config: DynamicAgentsModuleConfig -): DynamicAgentsModule { - const transport = createGatewayTransport(config); - - function create(agentConfig: AgentConfig): Agent { - return createAgent(agentConfig, transport); - } - - 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/ai-gateway.ts b/src/modules/ai-gateway.ts index 29a44b2..b55b419 100644 --- a/src/modules/ai-gateway.ts +++ b/src/modules/ai-gateway.ts @@ -1,11 +1,17 @@ import { Base44Error } from "../utils/axios-client.js"; -import type { DynamicAgentsModuleConfig } from "./dynamic-agents.types.js"; + +/** @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: DynamicAgentsModuleConfig): { +export function resolveConnection(config: GatewayConfig): { baseURL: string; apiKey: string; } { @@ -23,7 +29,7 @@ export function resolveConnection(config: DynamicAgentsModuleConfig): { * later without changing callers of `.complete()`. * @internal */ -export function createGatewayTransport(config: DynamicAgentsModuleConfig) { +export function createGatewayTransport(config: GatewayConfig) { return { async complete( body: Record, diff --git a/src/modules/dynamic-agents.ts b/src/modules/dynamic-agents.ts deleted file mode 100644 index 54448f6..0000000 --- a/src/modules/dynamic-agents.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Re-export shim — all implementations have moved to tool.ts and agent-loop.ts. -export { tool, serializeTools } from "./tool.js"; -export { createAgent, buildRequestBody, createDynamicAgentsModule } from "./agent-loop.js"; diff --git a/src/modules/dynamic-agents.types.ts b/src/modules/dynamic-agents.types.ts deleted file mode 100644 index 344cdb1..0000000 --- a/src/modules/dynamic-agents.types.ts +++ /dev/null @@ -1,36 +0,0 @@ -// src/modules/dynamic-agents.types.ts -// Re-export code-agent types from agents.types.ts (transition shim — kept for A2 additive phase) -export type { - JSONSchema, - Tool, - ChatMessage, - Step, - RunUsage, - RunResult, - RunInput, - RunOptions, - ToolChoice, - AgentConfig, - Agent, -} from "./agents.types.js"; - -/** The `base44.dynamicAgents` module. */ -export interface DynamicAgentsModule { - /** Define a reusable agent. */ - create(config: import("./agents.types.js").AgentConfig): import("./agents.types.js").Agent; - /** One-shot: `create(config).run({ prompt })`. */ - run(config: import("./agents.types.js").AgentConfig & import("./agents.types.js").RunInput, options?: import("./agents.types.js").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.types.ts b/src/modules/functions.types.ts index 9f2334f..29f55cf 100644 --- a/src/modules/functions.types.ts +++ b/src/modules/functions.types.ts @@ -101,7 +101,7 @@ export interface FunctionsModule { * @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; + asTool(name: FunctionName, opts: { description: string; parameters?: Record }): import("./agents.types.js").Tool; /** * Performs a direct HTTP request to a backend function path and returns the native `Response`. diff --git a/src/modules/tool.ts b/src/modules/tool.ts index 164d50a..1397865 100644 --- a/src/modules/tool.ts +++ b/src/modules/tool.ts @@ -1,4 +1,4 @@ -import type { Tool } from "./dynamic-agents.types.js"; +import type { Tool } from "./agents.types.js"; /** * Defines a tool an agent can call. diff --git a/tests/unit/dynamic-agents.test.ts b/tests/unit/agents-code.test.ts similarity index 83% rename from tests/unit/dynamic-agents.test.ts rename to tests/unit/agents-code.test.ts index a19db43..a3e4226 100644 --- a/tests/unit/dynamic-agents.test.ts +++ b/tests/unit/agents-code.test.ts @@ -1,8 +1,10 @@ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { createClient } from "../../src/index.ts"; +import * as sdk from "../../src/index.ts"; import { resolveConnection, createGatewayTransport } from "../../src/modules/ai-gateway.ts"; import { Base44Error } from "../../src/index.ts"; import { tool, serializeTools } from "../../src/modules/tool.ts"; -import { buildRequestBody, createDynamicAgentsModule } from "../../src/modules/agent-loop.ts"; +import { buildRequestBody, createAgent } from "../../src/modules/agent-loop.ts"; const config = { serverUrl: "https://app-1.base44.app", @@ -174,8 +176,9 @@ describe("agent loop", () => { 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" }); + const transport = createGatewayTransport(config); + const agent = createAgent({ model: "gpt_5_mini", system: "Be terse." }, transport); + const result = await agent.run({ prompt: "Hi" }); expect(result.text).toBe("Hello there."); expect(result.finishReason).toBe("stop"); @@ -189,7 +192,7 @@ describe("agent loop", () => { ]); }); - test("create().run() executes a tool then continues to a final answer", async () => { + test("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" }) @@ -197,11 +200,15 @@ describe("agent loop", () => { .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 transport = createGatewayTransport(config); + const agent = createAgent( + { + model: "claude_sonnet_4_6", + tools: { getWeather: { description: "weather", parameters: { type: "object" }, execute } }, + maxSteps: 4, + }, + transport + ); const result = await agent.run({ prompt: "weather in Haifa?" }); expect(execute).toHaveBeenCalledWith({ city: "Haifa" }); @@ -221,10 +228,14 @@ describe("agent loop", () => { 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 transport = createGatewayTransport(config); + const agent = createAgent( + { + model: "m", + tools: { boom: { description: "x", parameters: { type: "object" }, execute: async () => { throw new Error("kaboom"); } } }, + }, + 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"); @@ -235,11 +246,15 @@ describe("agent loop", () => { 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 transport = createGatewayTransport(config); + const agent = createAgent( + { + model: "m", + tools: { t: { description: "x", parameters: { type: "object" }, execute: async () => "ok" } }, + maxSteps: 2, + }, + transport + ); const result = await agent.run({ prompt: "loop" }); expect(result.finishReason).toBe("max_steps"); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -247,54 +262,20 @@ describe("agent loop", () => { 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 transport = createGatewayTransport(config); + const agent = createAgent({ model: "m" }, 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" }]); }); }); -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"); - }); -}); - describe("base44.agents.create client wiring", () => { let fetchMock: ReturnType; beforeEach(() => { diff --git a/tests/unit/as-tool.test.ts b/tests/unit/as-tool.test.ts index b6e17c0..ac8fad7 100644 --- a/tests/unit/as-tool.test.ts +++ b/tests/unit/as-tool.test.ts @@ -50,7 +50,7 @@ describe("Agent.asTool", () => { 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 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."); From da0ae3e91c9e8d858846748953a4f08551a7ec69 Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 10:00:50 +0300 Subject: [PATCH 16/29] docs(agents): bring code-agent public surface to repo JSDoc conventions - Agent.run and Agent.asTool: added full @param/@returns/@example peer-depth JSDoc - AgentConfig: expanded interface-level block + all seven fields documented - RunOptions: added @example with AbortController pattern - tool() factory: added @param/@returns/@example - No internal planning language found or removed (grep clean) - Runtime behavior, signatures, and tests unchanged Co-Authored-By: Claude Sonnet 4.6 --- src/modules/agents.types.ts | 213 ++++++++++++++++++++++++++++++++++-- src/modules/tool.ts | 27 ++++- 2 files changed, 226 insertions(+), 14 deletions(-) diff --git a/src/modules/agents.types.ts b/src/modules/agents.types.ts index 2cf08eb..d336467 100644 --- a/src/modules/agents.types.ts +++ b/src/modules/agents.types.ts @@ -72,7 +72,20 @@ export interface RunResult { /** Input to {@linkcode Agent.run}: either a single prompt or a full message list. */ export type RunInput = { prompt: string } | { messages: ChatMessage[] }; -/** Per-run options. */ +/** + * 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; @@ -85,30 +98,206 @@ export type ToolChoice = | "required" | { type: "function"; function: { name: string } }; -/** Configuration for a code-defined agent. */ +/** + * 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 (e.g. `"claude_sonnet_4_6"`, `"gpt_5_mini"`) or vendor id. */ + /** + * 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. */ + /** + * 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 name. */ + /** + * 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; - /** Max loop iterations before stopping. Default `8`. */ + /** + * 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. Omitted unless set. Note: GPT-5 models only accept `1`. */ + /** + * 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; - /** A JSON Schema to constrain output to structured JSON (`response_format: json_schema`). */ + /** + * 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`. + * + * @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/which tool the model must call. */ + /** + * 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. */ +/** + * 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 { - /** Run the agent's tool-calling loop to completion. */ + /** + * 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; + /** - * Turn this agent into a {@linkcode Tool} so another agent can call it as a sub-agent. + * 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. + * + * @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; } diff --git a/src/modules/tool.ts b/src/modules/tool.ts index 1397865..2bd869d 100644 --- a/src/modules/tool.ts +++ b/src/modules/tool.ts @@ -3,12 +3,35 @@ 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" } }, required: ["city"] }, - * execute: async ({ city }) => ({ city, tempC: 28 }), + * 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 }, * }); * ``` */ From bf3875e2ea92518592c62fbbe10f147f8d9468b2 Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 10:03:04 +0300 Subject: [PATCH 17/29] docs(agents): cross-link Agent in create() @returns --- src/modules/agents.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/agents.types.ts b/src/modules/agents.types.ts index d336467..1dac2f4 100644 --- a/src/modules/agents.types.ts +++ b/src/modules/agents.types.ts @@ -697,7 +697,7 @@ export interface AgentsModule { * another agent as a tool with {@linkcode Agent.asTool | asTool}. * * @param config - Model alias, optional system prompt, tools, and step limit. - * @returns A reusable agent. + * @returns A reusable {@linkcode Agent} with {@linkcode Agent.run | run()} and {@linkcode Agent.asTool | asTool()}. * * @example * ```typescript From 90d26747863d6630a2a8b0c89e8e42c13485558b Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 10:05:46 +0300 Subject: [PATCH 18/29] test(agents): align test conventions and add type-level tests Co-Authored-By: Claude Sonnet 4.6 --- tests/types/agents-code.types.ts | 71 ++++++++++++++++++ tests/unit/agents-code.test.ts | 120 +++++++++++++++++++------------ tests/unit/as-tool.test.ts | 15 ++-- 3 files changed, 156 insertions(+), 50 deletions(-) create mode 100644 tests/types/agents-code.types.ts diff --git a/tests/types/agents-code.types.ts b/tests/types/agents-code.types.ts new file mode 100644 index 0000000..f4563a6 --- /dev/null +++ b/tests/types/agents-code.types.ts @@ -0,0 +1,71 @@ +import type { RunInput, ToolChoice, AgentConfig, Agent, ChatMessage } 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() +// --------------------------------------------------------------------------- + +// Verify that the return type of create() satisfies the Agent interface. +declare function createAgent(config: AgentConfig): Agent; + +const agent: Agent = createAgent({ 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. + {} +); diff --git a/tests/unit/agents-code.test.ts b/tests/unit/agents-code.test.ts index a3e4226..4b2a5c0 100644 --- a/tests/unit/agents-code.test.ts +++ b/tests/unit/agents-code.test.ts @@ -1,8 +1,8 @@ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { createClient } from "../../src/index.ts"; import * as sdk from "../../src/index.ts"; -import { resolveConnection, createGatewayTransport } from "../../src/modules/ai-gateway.ts"; import { Base44Error } from "../../src/index.ts"; +import { resolveConnection, createGatewayTransport } from "../../src/modules/ai-gateway.ts"; import { tool, serializeTools } from "../../src/modules/tool.ts"; import { buildRequestBody, createAgent } from "../../src/modules/agent-loop.ts"; @@ -11,7 +11,39 @@ const config = { getToken: () => "tok-123", }; -describe("ai-gateway transport", () => { +// --------------------------------------------------------------------------- +// 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(() => { @@ -23,14 +55,14 @@ describe("ai-gateway transport", () => { vi.clearAllMocks(); }); - test("resolveConnection builds the gateway baseURL and apiKey", () => { + 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("complete() POSTs to /chat/completions with bearer auth and returns parsed body", async () => { + 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: [] }), { @@ -51,7 +83,7 @@ describe("ai-gateway transport", () => { expect(JSON.parse(init.body as string)).toEqual(body); }); - test("complete() maps the OpenAI error envelope to a Base44Error", async () => { + test("should map an OpenAI error envelope to a Base44Error", async () => { fetchMock.mockResolvedValue( new Response( JSON.stringify({ @@ -70,13 +102,17 @@ describe("ai-gateway transport", () => { }); }); +// --------------------------------------------------------------------------- +// tool() + serializeTools() +// --------------------------------------------------------------------------- + describe("tool() + serializeTools()", () => { - test("tool() returns its argument unchanged", () => { + test("should return 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", () => { + test("should map to OpenAI function-tool shape", () => { const getWeather = { description: "Get weather", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, @@ -94,23 +130,27 @@ describe("tool() + serializeTools()", () => { ]); }); - test("serializeTools returns undefined when there are no tools", () => { + test("should return undefined when there are no tools", () => { expect(serializeTools(undefined)).toBeUndefined(); expect(serializeTools({})).toBeUndefined(); }); }); +// --------------------------------------------------------------------------- +// buildRequestBody() +// --------------------------------------------------------------------------- + describe("buildRequestBody()", () => { const messages = [{ role: "user" as const, content: "hi" }]; - test("emits only model and messages by default (temperature omitted)", () => { + test("should emit 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", () => { + test("should include temperature, tool_choice, response_format and tools when set", () => { const body = buildRequestBody( { model: "claude_sonnet_4_6", @@ -130,7 +170,7 @@ describe("buildRequestBody()", () => { expect(Array.isArray(body.tools)).toBe(true); }); - test("never emits rejected params even if smuggled in via cast", () => { + test("should never emit 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"]) { @@ -139,31 +179,11 @@ describe("buildRequestBody()", () => { }); }); -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" } } - ); -} +// --------------------------------------------------------------------------- +// Agent loop (createAgent) +// --------------------------------------------------------------------------- -describe("agent loop", () => { +describe("Agent loop", () => { let fetchMock: ReturnType; beforeEach(() => { fetchMock = vi.fn(); @@ -174,7 +194,7 @@ describe("agent loop", () => { vi.clearAllMocks(); }); - test("run() returns text, usage (incl. credits), and finishReason on a no-tool completion", async () => { + 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." }, transport); @@ -192,7 +212,7 @@ describe("agent loop", () => { ]); }); - test("run() executes a tool then continues to a final answer", async () => { + 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" }) @@ -222,7 +242,7 @@ describe("agent loop", () => { 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 () => { + 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" }) @@ -242,7 +262,7 @@ describe("agent loop", () => { expect(toolMsg.content).toContain("Error: kaboom"); }); - test("stops at maxSteps with finishReason 'max_steps'", async () => { + test("should stop at maxSteps with finishReason 'max_steps'", async () => { fetchMock.mockImplementation(() => completion({ toolCalls: [{ id: "c", name: "t", arguments: "{}" }], finish: "tool_calls" }) ); @@ -260,7 +280,7 @@ describe("agent loop", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); - test("run() accepts a full messages array", async () => { + 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" }, transport); @@ -270,13 +290,21 @@ describe("agent loop", () => { }); }); -describe("public exports", () => { - test("tool is exported from the package root", () => { +// --------------------------------------------------------------------------- +// Public package exports +// --------------------------------------------------------------------------- + +describe("Public package exports", () => { + test("should export tool from the package root", () => { expect(typeof sdk.tool).toBe("function"); }); }); -describe("base44.agents.create client wiring", () => { +// --------------------------------------------------------------------------- +// base44.agents.create — client wiring +// --------------------------------------------------------------------------- + +describe("base44.agents.create — client wiring", () => { let fetchMock: ReturnType; beforeEach(() => { fetchMock = vi.fn().mockResolvedValue(completion({ content: "agent-ok" })); @@ -287,7 +315,7 @@ describe("base44.agents.create client wiring", () => { vi.clearAllMocks(); }); - test("base44.agents.create().run() hits the gateway with the user token", async () => { + 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" }); @@ -298,7 +326,7 @@ describe("base44.agents.create client wiring", () => { expect(init.headers.Authorization).toBe("Bearer user-tok-2"); }); - test("asServiceRole.agents.create().run() hits the gateway with the service token", async () => { + test("should hit the gateway with the service token via asServiceRole", async () => { const base44 = createClient({ serverUrl: "https://app-y.base44.app", appId: "app-y", @@ -313,7 +341,7 @@ describe("base44.agents.create client wiring", () => { expect(init.headers.Authorization).toBe("Bearer svc-tok-2"); }); - test("base44.agents.create().run() runs a full tool-calling loop", async () => { + 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" }) diff --git a/tests/unit/as-tool.test.ts b/tests/unit/as-tool.test.ts index ac8fad7..caed002 100644 --- a/tests/unit/as-tool.test.ts +++ b/tests/unit/as-tool.test.ts @@ -1,4 +1,3 @@ -// tests/unit/as-tool.test.ts import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { createClient } from "../../src/index.ts"; @@ -11,12 +10,16 @@ function reply(content: string) { ); } +// --------------------------------------------------------------------------- +// functions.asTool +// --------------------------------------------------------------------------- + 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 () => { + 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") @@ -36,19 +39,23 @@ describe("functions.asTool", () => { scope.done(); }); - test("defaults parameters to an open object when omitted", () => { + 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 }); }); }); +// --------------------------------------------------------------------------- +// Agent.asTool +// --------------------------------------------------------------------------- + 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 () => { + 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." }); From fb62a086c3cf7a20d73e726cd90c5dc0987a651a Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 10:09:17 +0300 Subject: [PATCH 19/29] test(agents): exercise real AgentsModule.create return type in type tests --- tests/types/agents-code.types.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/types/agents-code.types.ts b/tests/types/agents-code.types.ts index f4563a6..6321c19 100644 --- a/tests/types/agents-code.types.ts +++ b/tests/types/agents-code.types.ts @@ -1,4 +1,4 @@ -import type { RunInput, ToolChoice, AgentConfig, Agent, ChatMessage } from "../../src/index.js"; +import type { RunInput, ToolChoice, Agent, ChatMessage, AgentsModule } from "../../src/index.js"; // --------------------------------------------------------------------------- // RunInput — union of { prompt: string } | { messages: ChatMessage[] } @@ -52,10 +52,11 @@ const rejectsMissingFunctionName = ( // base44.agents.create() return type — Agent exposes run() and asTool() // --------------------------------------------------------------------------- -// Verify that the return type of create() satisfies the Agent interface. -declare function createAgent(config: AgentConfig): Agent; +// 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 = createAgent({ model: "claude_sonnet_4_6" }); +const agent: Agent = agents.create({ model: "claude_sonnet_4_6" }); // run exists and returns a Promise const _runResult: ReturnType = agent.run({ prompt: "hello" }); From 3b30d06a99668cb79b7a388899f5dec4687e24c3 Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 10:12:07 +0300 Subject: [PATCH 20/29] refactor(agents): type the gateway completion shapes; drop loop any-casts Co-Authored-By: Claude Sonnet 4.6 --- src/modules/agent-loop.ts | 69 ++++++++++++++++++++++++++++++++------- src/modules/ai-gateway.ts | 3 +- src/modules/tool.ts | 17 ++++++++-- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/modules/agent-loop.ts b/src/modules/agent-loop.ts index d0d69ee..8aae1d7 100644 --- a/src/modules/agent-loop.ts +++ b/src/modules/agent-loop.ts @@ -10,6 +10,52 @@ import type { } from "./agents.types.js"; import { serializeTools } from "./tool.js"; +// --------------------------------------------------------------------------- +// Internal interfaces for the OpenAI-compatible completion shape. +// These cover only the fields the loop actually reads; unknown extra fields +// from the gateway are tolerated (no `[key: string]: unknown` needed since +// the response is typed as the intersection we care about). +// --------------------------------------------------------------------------- + +/** @internal */ +export interface OpenAIToolCall { + id: string; + type: "function"; + function: { name: string; arguments: string }; +} + +/** @internal */ +export interface OpenAIAssistantMessage { + role: "assistant"; + content?: string | null; + tool_calls?: OpenAIToolCall[]; +} + +/** @internal */ +export interface OpenAIChoice { + message: OpenAIAssistantMessage; + finish_reason?: string; +} + +/** @internal */ +export interface OpenAIUsage { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + /** Base44 gateway credit cost for the request. */ + base44_credits?: number; +} + +/** + * The subset of an OpenAI-compatible chat completion response that the loop reads. + * The gateway may return additional fields; they are tolerated but not accessed. + * @internal + */ +export interface OpenAICompletion { + choices: OpenAIChoice[]; + usage?: OpenAIUsage; +} + /** * 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 @@ -48,8 +94,8 @@ function stringifyResult(out: unknown): string { return typeof out === "string" ? out : JSON.stringify(out); } -function mapUsage(raw: any): RunResult["usage"] { - const u = (raw && raw.usage) || {}; +function mapUsage(raw: OpenAICompletion | null): RunResult["usage"] { + const u = raw?.usage ?? {}; return { promptTokens: u.prompt_tokens, completionTokens: u.completion_tokens, @@ -64,7 +110,7 @@ function mapUsage(raw: any): RunResult["usage"] { */ export function createAgent( agentConfig: AgentConfig, - transport: { complete(body: Record, opts?: { signal?: AbortSignal }): Promise } + transport: { complete(body: Record, opts?: { signal?: AbortSignal }): Promise } ): Agent { const maxSteps = agentConfig.maxSteps ?? DEFAULT_MAX_STEPS; const tools = agentConfig.tools; @@ -76,14 +122,14 @@ export function createAgent( messages.push(...inputToMessages(input)); const steps: Step[] = []; - let raw: any = null; + let raw: OpenAICompletion | null = 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: "" }; + const choice = raw.choices[0]; + const message: OpenAIAssistantMessage = choice?.message ?? { role: "assistant", content: "" }; messages.push(message); const toolCalls = message.tool_calls; @@ -99,11 +145,11 @@ export function createAgent( const toolResults: Step["toolResults"] = []; for (const call of toolCalls) { - const name = call.function?.name; + const name = call.function.name; const t = tools?.[name]; let args: unknown = {}; try { - args = JSON.parse(call.function?.arguments || "{}"); + args = JSON.parse(call.function.arguments || "{}"); } catch { args = {}; } @@ -113,8 +159,9 @@ export function createAgent( } else { try { resultContent = stringifyResult(await t.execute(args)); - } catch (e: any) { - resultContent = `Error: ${e?.message ?? String(e)}`; + } catch (e: unknown) { + const err = e as { message?: string }; + resultContent = `Error: ${err?.message ?? String(e)}`; } } messages.push({ role: "tool", tool_call_id: call.id, content: resultContent }); @@ -124,7 +171,7 @@ export function createAgent( } // maxSteps exhausted - const lastMessage = raw?.choices?.[0]?.message; + const lastMessage = raw?.choices[0]?.message; return { text: lastMessage?.content ?? "", steps, diff --git a/src/modules/ai-gateway.ts b/src/modules/ai-gateway.ts index b55b419..638792a 100644 --- a/src/modules/ai-gateway.ts +++ b/src/modules/ai-gateway.ts @@ -1,4 +1,5 @@ import { Base44Error } from "../utils/axios-client.js"; +import type { OpenAICompletion } from "./agent-loop.js"; /** @internal */ export interface GatewayConfig { @@ -34,7 +35,7 @@ export function createGatewayTransport(config: GatewayConfig) { async complete( body: Record, opts: { signal?: AbortSignal } = {} - ): Promise { + ): Promise { const { baseURL, apiKey } = resolveConnection(config); const res = await fetch(`${baseURL}/chat/completions`, { method: "POST", diff --git a/src/modules/tool.ts b/src/modules/tool.ts index 2bd869d..8f9abd3 100644 --- a/src/modules/tool.ts +++ b/src/modules/tool.ts @@ -1,4 +1,17 @@ -import type { Tool } from "./agents.types.js"; +import type { JSONSchema, Tool } from "./agents.types.js"; + +/** + * The OpenAI function-tool format sent in the `tools[]` request array. + * @internal + */ +export interface OpenAIToolDef { + type: "function"; + function: { + name: string; + description: string; + parameters: JSONSchema; + }; +} /** * Defines a tool an agent can call. @@ -44,7 +57,7 @@ export function tool(t: Tool): Tool { * when empty so the param is omitted from the request body. * @internal */ -export function serializeTools(tools?: Record): any[] | undefined { +export function serializeTools(tools?: Record): OpenAIToolDef[] | undefined { if (!tools) return undefined; const entries = Object.entries(tools); if (entries.length === 0) return undefined; From db81086efd76152cfd55d7b510f1b3e95bdc2162 Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 10:14:20 +0300 Subject: [PATCH 21/29] fix(agents): restore optional-chain on choices to keep malformed-response behavior identical --- src/modules/agent-loop.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/agent-loop.ts b/src/modules/agent-loop.ts index 8aae1d7..aa4fa25 100644 --- a/src/modules/agent-loop.ts +++ b/src/modules/agent-loop.ts @@ -128,7 +128,7 @@ export function createAgent( const body = buildRequestBody(agentConfig, messages); raw = await transport.complete(body, { signal: options.abortSignal }); - const choice = raw.choices[0]; + const choice = raw.choices?.[0]; const message: OpenAIAssistantMessage = choice?.message ?? { role: "assistant", content: "" }; messages.push(message); @@ -171,7 +171,7 @@ export function createAgent( } // maxSteps exhausted - const lastMessage = raw?.choices[0]?.message; + const lastMessage = raw?.choices?.[0]?.message; return { text: lastMessage?.content ?? "", steps, From 9d8366ae26f0ade37d30d04991df5ec243ed79b8 Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 11:21:46 +0300 Subject: [PATCH 22/29] refactor(agents): move agents module + internals into src/modules/agents/ Co-Authored-By: Claude Sonnet 4.6 --- src/client.ts | 2 +- src/client.types.ts | 2 +- src/index.ts | 6 +++--- src/modules/{ => agents}/agents.types.ts | 4 ++-- src/modules/{ai-gateway.ts => agents/gateway.ts} | 4 ++-- src/modules/{agents.ts => agents/index.ts} | 8 ++++---- src/modules/{agent-loop.ts => agents/loop.ts} | 0 src/modules/{ => agents}/tool.ts | 0 src/modules/functions.types.ts | 2 +- src/modules/types.ts | 2 +- tests/unit/agents-code.test.ts | 6 +++--- 11 files changed, 18 insertions(+), 18 deletions(-) rename src/modules/{ => agents}/agents.types.ts (99%) rename src/modules/{ai-gateway.ts => agents/gateway.ts} (94%) rename src/modules/{agents.ts => agents/index.ts} (95%) rename src/modules/{agent-loop.ts => agents/loop.ts} (100%) rename src/modules/{ => agents}/tool.ts (100%) diff --git a/src/client.ts b/src/client.ts index 27623ba..a155982 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"; diff --git a/src/client.types.ts b/src/client.types.ts index 6b4c9c5..15d87ae 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 fde8817..ac8fb7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import { removeAccessToken, getLoginUrl, } from "./utils/auth-utils.js"; -import { tool } from "./modules/tool.js"; +import { tool } from "./modules/agents/tool.js"; export { createClient, @@ -100,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"; @@ -129,7 +129,7 @@ export type { ToolChoice, AgentConfig, Agent, -} from "./modules/agents.types.js"; +} from "./modules/agents/agents.types.js"; // Auth utils types export type { diff --git a/src/modules/agents.types.ts b/src/modules/agents/agents.types.ts similarity index 99% rename from src/modules/agents.types.ts rename to src/modules/agents/agents.types.ts index 1dac2f4..250e9e9 100644 --- a/src/modules/agents.types.ts +++ b/src/modules/agents/agents.types.ts @@ -1,6 +1,6 @@ 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"; // --------------------------------------------------------------------------- // Code-agent types (moved from dynamic-agents.types.ts) diff --git a/src/modules/ai-gateway.ts b/src/modules/agents/gateway.ts similarity index 94% rename from src/modules/ai-gateway.ts rename to src/modules/agents/gateway.ts index 638792a..78c9e52 100644 --- a/src/modules/ai-gateway.ts +++ b/src/modules/agents/gateway.ts @@ -1,5 +1,5 @@ -import { Base44Error } from "../utils/axios-client.js"; -import type { OpenAICompletion } from "./agent-loop.js"; +import { Base44Error } from "../../utils/axios-client.js"; +import type { OpenAICompletion } from "./loop.js"; /** @internal */ export interface GatewayConfig { diff --git a/src/modules/agents.ts b/src/modules/agents/index.ts similarity index 95% rename from src/modules/agents.ts rename to src/modules/agents/index.ts index 4293aad..d9e9492 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,8 +7,8 @@ import { AgentsModuleConfig, CreateConversationParams, } from "./agents.types.js"; -import { createGatewayTransport } from "./ai-gateway.js"; -import { createAgent } from "./agent-loop.js"; +import { createGatewayTransport } from "./gateway.js"; +import { createAgent } from "./loop.js"; export function createAgentsModule({ axios, diff --git a/src/modules/agent-loop.ts b/src/modules/agents/loop.ts similarity index 100% rename from src/modules/agent-loop.ts rename to src/modules/agents/loop.ts diff --git a/src/modules/tool.ts b/src/modules/agents/tool.ts similarity index 100% rename from src/modules/tool.ts rename to src/modules/agents/tool.ts diff --git a/src/modules/functions.types.ts b/src/modules/functions.types.ts index 29f55cf..0a699c3 100644 --- a/src/modules/functions.types.ts +++ b/src/modules/functions.types.ts @@ -101,7 +101,7 @@ export interface FunctionsModule { * @param name - The backend function name. * @param opts - `description` (required) and optional JSON Schema `parameters`. */ - asTool(name: FunctionName, opts: { description: string; parameters?: Record }): import("./agents.types.js").Tool; + asTool(name: FunctionName, opts: { description: string; parameters?: Record }): import("./agents/agents.types.js").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 c7423e6..f3ee2b6 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/unit/agents-code.test.ts b/tests/unit/agents-code.test.ts index 4b2a5c0..29f9022 100644 --- a/tests/unit/agents-code.test.ts +++ b/tests/unit/agents-code.test.ts @@ -2,9 +2,9 @@ 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/ai-gateway.ts"; -import { tool, serializeTools } from "../../src/modules/tool.ts"; -import { buildRequestBody, createAgent } from "../../src/modules/agent-loop.ts"; +import { resolveConnection, createGatewayTransport } from "../../src/modules/agents/gateway.ts"; +import { tool, serializeTools } from "../../src/modules/agents/tool.ts"; +import { buildRequestBody, createAgent } from "../../src/modules/agents/loop.ts"; const config = { serverUrl: "https://app-1.base44.app", From a90969471d4e4ce10ab5b043ed333b4388428cb1 Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 11:25:54 +0300 Subject: [PATCH 23/29] feat(agents): add neutral LanguageModel seam + openAIProvider adapter Co-Authored-By: Claude Sonnet 4.6 --- src/modules/agents/provider.ts | 48 +++++++++++++++ src/modules/agents/providers/openai.ts | 83 ++++++++++++++++++++++++++ tests/unit/agents-provider.test.ts | 73 ++++++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 src/modules/agents/provider.ts create mode 100644 src/modules/agents/providers/openai.ts create mode 100644 tests/unit/agents-provider.test.ts diff --git a/src/modules/agents/provider.ts b/src/modules/agents/provider.ts new file mode 100644 index 0000000..224e3e7 --- /dev/null +++ b/src/modules/agents/provider.ts @@ -0,0 +1,48 @@ +import type { Tool, ToolChoice, JSONSchema, RunUsage } 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 NOT here — it is a + * first-class field on {@link GenerateRequest}. Content is a string for now; a + * parts-array variant can be added later for multimodal without breaking this union. + * @internal + */ +export type ModelMessage = + | { role: "user"; content: string } + | { role: "assistant"; content?: string; toolCalls?: ModelToolCall[] } + | { role: "tool"; toolCallId: string; toolName: string; result: string }; + +/** Finish reasons normalized across providers. @internal */ +export type FinishReason = "stop" | "length" | "tool-calls" | "content-filter" | "error" | "other"; + +/** A request to a language model. @internal */ +export interface GenerateRequest { + model: string; + system?: 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; + // Phase 1B: stream(req: GenerateRequest): AsyncIterable; +} diff --git a/src/modules/agents/providers/openai.ts b/src/modules/agents/providers/openai.ts new file mode 100644 index 0000000..4c7a3fd --- /dev/null +++ b/src/modules/agents/providers/openai.ts @@ -0,0 +1,83 @@ +import type { Tool, ToolChoice, JSONSchema, RunUsage } from "../agents.types.js"; +import type { GenerateRequest, GenerateResult, LanguageModel, ModelMessage, ModelToolCall, FinishReason } from "../provider.js"; + +interface GatewayTransport { complete(body: Record, opts?: { signal?: AbortSignal }): Promise } + +interface OpenAIToolDef { type: "function"; function: { name: string; description: string; parameters: JSONSchema } } + +function serializeTools(tools?: Record): OpenAIToolDef[] | 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 } })); +} + +/** Neutral messages (+ system) -> OpenAI chat messages. */ +function toOpenAIMessages(system: string | undefined, messages: ModelMessage[]): Record[] { + const out: Record[] = []; + if (system) out.push({ role: "system", content: system }); + for (const m of messages) { + if (m.role === "user") out.push({ role: "user", content: m.content }); + else if (m.role === "assistant") { + const msg: Record = { role: "assistant", content: m.content ?? null }; + if (m.toolCalls?.length) { + msg.tool_calls = m.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: JSON.stringify(c.args ?? {}) } })); + } + out.push(msg); + } else { + out.push({ role: "tool", tool_call_id: m.toolCallId, content: m.result }); + } + } + return out; +} + +/** Build the OpenAI body using the same param whitelist as before (rejected params can never appear). */ +function buildOpenAIBody(req: GenerateRequest): Record { + const body: Record = { model: req.model, messages: toOpenAIMessages(req.system, 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 parseOpenAICompletion(raw: any): GenerateResult { + const choice = raw?.choices?.[0]; + const message = choice?.message ?? {}; + const toolCalls: ModelToolCall[] = (message.tool_calls ?? []).map((c: any) => { + let args: unknown = {}; + try { args = JSON.parse(c.function?.arguments || "{}"); } catch { args = {}; } + return { id: c.id, name: c.function?.name, args }; + }); + const u = raw?.usage ?? {}; + const usage: RunUsage = { promptTokens: u.prompt_tokens, completionTokens: u.completion_tokens, totalTokens: u.total_tokens, credits: u.base44_credits }; + return { + text: message.content ?? "", + toolCalls, + finishReason: normalizeFinish(choice?.finish_reason, toolCalls.length > 0), + usage, + raw, + }; +} + +/** OpenAI-compatible adapter over the Base44 gateway transport. @internal */ +export function openAIProvider(transport: GatewayTransport): LanguageModel { + return { + async generate(req: GenerateRequest): Promise { + const raw = await transport.complete(buildOpenAIBody(req), { signal: req.signal }); + return parseOpenAICompletion(raw); + }, + }; +} diff --git a/tests/unit/agents-provider.test.ts b/tests/unit/agents-provider.test.ts new file mode 100644 index 0000000..6b9d390 --- /dev/null +++ b/tests/unit/agents-provider.test.ts @@ -0,0 +1,73 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { openAIProvider } from "../../src/modules/agents/providers/openai.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("openAIProvider 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 openAIProvider(createGatewayTransport({ serverUrl: "https://a.base44.app", getToken: () => "t" })); + }; + + test("system is sent as a system message; user/assistant/tool messages serialized to OpenAI shape", 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", system: "Be terse.", + messages: [{ 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({ promptTokens: 3, completionTokens: 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}' }); + }); +}); From d95048c0fac3931730e7ebb05d4163c6bd2497da Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 11:26:52 +0300 Subject: [PATCH 24/29] chore(agents): drop unused imports in provider adapter + test --- src/modules/agents/providers/openai.ts | 2 +- tests/unit/agents-provider.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/agents/providers/openai.ts b/src/modules/agents/providers/openai.ts index 4c7a3fd..67d4abc 100644 --- a/src/modules/agents/providers/openai.ts +++ b/src/modules/agents/providers/openai.ts @@ -1,4 +1,4 @@ -import type { Tool, ToolChoice, JSONSchema, RunUsage } from "../agents.types.js"; +import type { Tool, JSONSchema, RunUsage } from "../agents.types.js"; import type { GenerateRequest, GenerateResult, LanguageModel, ModelMessage, ModelToolCall, FinishReason } from "../provider.js"; interface GatewayTransport { complete(body: Record, opts?: { signal?: AbortSignal }): Promise } diff --git a/tests/unit/agents-provider.test.ts b/tests/unit/agents-provider.test.ts index 6b9d390..7d1b2ea 100644 --- a/tests/unit/agents-provider.test.ts +++ b/tests/unit/agents-provider.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, test, expect, afterEach, vi } from "vitest"; import { openAIProvider } from "../../src/modules/agents/providers/openai.ts"; const transportFor = (body: object) => { From 1822ff0a6fdadb19da2bfdb0293f1a637626e145 Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 11:30:54 +0300 Subject: [PATCH 25/29] refactor(agents): loop talks to the LanguageModel seam; OpenAI logic confined to the adapter Co-Authored-By: Claude Sonnet 4.6 --- src/modules/agents/gateway.ts | 3 +- src/modules/agents/index.ts | 7 +- src/modules/agents/loop.ts | 201 +++++++-------------------------- src/modules/agents/tool.ts | 30 +---- tests/unit/agents-code.test.ts | 85 ++------------ 5 files changed, 59 insertions(+), 267 deletions(-) diff --git a/src/modules/agents/gateway.ts b/src/modules/agents/gateway.ts index 78c9e52..4ed3933 100644 --- a/src/modules/agents/gateway.ts +++ b/src/modules/agents/gateway.ts @@ -1,5 +1,4 @@ import { Base44Error } from "../../utils/axios-client.js"; -import type { OpenAICompletion } from "./loop.js"; /** @internal */ export interface GatewayConfig { @@ -35,7 +34,7 @@ export function createGatewayTransport(config: GatewayConfig) { async complete( body: Record, opts: { signal?: AbortSignal } = {} - ): Promise { + ): Promise { const { baseURL, apiKey } = resolveConnection(config); const res = await fetch(`${baseURL}/chat/completions`, { method: "POST", diff --git a/src/modules/agents/index.ts b/src/modules/agents/index.ts index d9e9492..926cc48 100644 --- a/src/modules/agents/index.ts +++ b/src/modules/agents/index.ts @@ -8,6 +8,7 @@ import { CreateConversationParams, } from "./agents.types.js"; import { createGatewayTransport } from "./gateway.js"; +import { openAIProvider } from "./providers/openai.js"; import { createAgent } from "./loop.js"; export function createAgentsModule({ @@ -18,10 +19,10 @@ export function createAgentsModule({ token, getToken, }: AgentsModuleConfig): AgentsModule { - const transport = createGatewayTransport({ + const model = openAIProvider(createGatewayTransport({ serverUrl: serverUrl ?? "", getToken: getToken ?? (() => token), - }); + })); const baseURL = `/apps/${appId}/agents`; // Track active conversations @@ -142,6 +143,6 @@ export function createAgentsModule({ subscribeToConversation, getWhatsAppConnectURL, getTelegramConnectURL, - create(config) { return createAgent(config, transport); }, + create(config) { return createAgent(config, model); }, }; } diff --git a/src/modules/agents/loop.ts b/src/modules/agents/loop.ts index aa4fa25..39ae792 100644 --- a/src/modules/agents/loop.ts +++ b/src/modules/agents/loop.ts @@ -1,92 +1,17 @@ -import type { - Agent, - AgentConfig, - ChatMessage, - RunInput, - RunOptions, - RunResult, - Step, - Tool, -} from "./agents.types.js"; -import { serializeTools } from "./tool.js"; - -// --------------------------------------------------------------------------- -// Internal interfaces for the OpenAI-compatible completion shape. -// These cover only the fields the loop actually reads; unknown extra fields -// from the gateway are tolerated (no `[key: string]: unknown` needed since -// the response is typed as the intersection we care about). -// --------------------------------------------------------------------------- - -/** @internal */ -export interface OpenAIToolCall { - id: string; - type: "function"; - function: { name: string; arguments: string }; -} - -/** @internal */ -export interface OpenAIAssistantMessage { - role: "assistant"; - content?: string | null; - tool_calls?: OpenAIToolCall[]; -} - -/** @internal */ -export interface OpenAIChoice { - message: OpenAIAssistantMessage; - finish_reason?: string; -} - -/** @internal */ -export interface OpenAIUsage { - prompt_tokens?: number; - completion_tokens?: number; - total_tokens?: number; - /** Base44 gateway credit cost for the request. */ - base44_credits?: number; -} - -/** - * The subset of an OpenAI-compatible chat completion response that the loop reads. - * The gateway may return additional fields; they are tolerated but not accessed. - * @internal - */ -export interface OpenAICompletion { - choices: OpenAIChoice[]; - usage?: OpenAIUsage; -} - -/** - * 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; -} +import type { Agent, AgentConfig, RunInput, RunOptions, RunResult, Step, Tool } from "./agents.types.js"; +import type { LanguageModel, ModelMessage } from "./provider.js"; const DEFAULT_MAX_STEPS = 8; -function inputToMessages(input: RunInput): ChatMessage[] { - if ("messages" in input) return input.messages; +function inputToMessages(input: RunInput): ModelMessage[] { + if ("messages" in input) { + // RunInput messages are the public ChatMessage[]; map to neutral user/assistant text. + return input.messages.map((m) => + m.role === "assistant" + ? { role: "assistant" as const, content: typeof m.content === "string" ? m.content : "" } + : { role: "user" as const, content: typeof m.content === "string" ? m.content : "" } + ); + } return [{ role: "user", content: input.prompt }]; } @@ -94,109 +19,69 @@ function stringifyResult(out: unknown): string { return typeof out === "string" ? out : JSON.stringify(out); } -function mapUsage(raw: OpenAICompletion | null): RunResult["usage"] { - const u = raw?.usage ?? {}; - return { - promptTokens: u.prompt_tokens, - completionTokens: u.completion_tokens, - totalTokens: u.total_tokens, - credits: u.base44_credits, - }; -} - -/** - * Creates an Agent from a config and a gateway transport. - * @internal - */ -export function createAgent( - agentConfig: AgentConfig, - transport: { complete(body: Record, opts?: { signal?: AbortSignal }): Promise } -): Agent { +/** 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: ChatMessage[] = []; - if (agentConfig.system) messages.push({ role: "system", content: agentConfig.system }); - messages.push(...inputToMessages(input)); - + const messages: ModelMessage[] = inputToMessages(input); const steps: Step[] = []; - let raw: OpenAICompletion | null = null; + let last: Awaited> | null = 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: OpenAIAssistantMessage = 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, - }; + last = await model.generate({ + model: agentConfig.model, + system: agentConfig.system, + messages, + tools, + temperature: agentConfig.temperature, + toolChoice: agentConfig.toolChoice, + responseFormat: agentConfig.responseFormat, + signal: options.abortSignal, + }); + + messages.push({ + role: "assistant", + content: last.text || undefined, + toolCalls: last.toolCalls.length ? last.toolCalls : undefined, + }); + + if (last.toolCalls.length === 0) { + return { text: last.text, steps, finishReason: last.finishReason, usage: last.usage, raw: last.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 = {}; - } + for (const call of last.toolCalls) { + const t = tools?.[call.name]; let resultContent: string; if (!t) { - resultContent = `Error: tool "${name}" is not available.`; + resultContent = `Error: tool "${call.name}" is not available.`; } else { try { - resultContent = stringifyResult(await t.execute(args)); + resultContent = stringifyResult(await t.execute(call.args)); } catch (e: unknown) { const err = e as { message?: string }; resultContent = `Error: ${err?.message ?? String(e)}`; } } - messages.push({ role: "tool", tool_call_id: call.id, content: resultContent }); - toolResults.push({ toolCallId: call.id, toolName: name, args, result: resultContent }); + 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 }); } - // maxSteps exhausted - const lastMessage = raw?.choices?.[0]?.message; - return { - text: lastMessage?.content ?? "", - steps, - finishReason: "max_steps", - usage: mapUsage(raw), - raw, - }; + return { text: last?.text ?? "", steps, finishReason: "max_steps", usage: last?.usage ?? {}, raw: last?.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; - }, + parameters: { type: "object", properties: { prompt: { type: "string", description: "What to ask the sub-agent." } }, required: ["prompt"] }, + execute: async (args: { prompt: string }) => (await agent.run({ prompt: args.prompt })).text, }; }, }; - return agent; } - diff --git a/src/modules/agents/tool.ts b/src/modules/agents/tool.ts index 8f9abd3..be79f2d 100644 --- a/src/modules/agents/tool.ts +++ b/src/modules/agents/tool.ts @@ -1,17 +1,4 @@ -import type { JSONSchema, Tool } from "./agents.types.js"; - -/** - * The OpenAI function-tool format sent in the `tools[]` request array. - * @internal - */ -export interface OpenAIToolDef { - type: "function"; - function: { - name: string; - description: string; - parameters: JSONSchema; - }; -} +import type { Tool } from "./agents.types.js"; /** * Defines a tool an agent can call. @@ -51,18 +38,3 @@ export interface OpenAIToolDef { 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): OpenAIToolDef[] | 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 }, - })); -} diff --git a/tests/unit/agents-code.test.ts b/tests/unit/agents-code.test.ts index 29f9022..5e178b0 100644 --- a/tests/unit/agents-code.test.ts +++ b/tests/unit/agents-code.test.ts @@ -3,8 +3,9 @@ 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, serializeTools } from "../../src/modules/agents/tool.ts"; -import { buildRequestBody, createAgent } from "../../src/modules/agents/loop.ts"; +import { tool } from "../../src/modules/agents/tool.ts"; +import { createAgent } from "../../src/modules/agents/loop.ts"; +import { openAIProvider } from "../../src/modules/agents/providers/openai.ts"; const config = { serverUrl: "https://app-1.base44.app", @@ -103,80 +104,14 @@ describe("AI Gateway transport", () => { }); // --------------------------------------------------------------------------- -// tool() + serializeTools() +// tool() // --------------------------------------------------------------------------- -describe("tool() + serializeTools()", () => { +describe("tool()", () => { test("should return its argument unchanged", () => { const t = { description: "d", parameters: { type: "object" }, execute: () => 1 }; expect(tool(t)).toBe(t); }); - - test("should map 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("should return undefined when there are no tools", () => { - expect(serializeTools(undefined)).toBeUndefined(); - expect(serializeTools({})).toBeUndefined(); - }); -}); - -// --------------------------------------------------------------------------- -// buildRequestBody() -// --------------------------------------------------------------------------- - -describe("buildRequestBody()", () => { - const messages = [{ role: "user" as const, content: "hi" }]; - - test("should emit only model and messages by default (temperature omitted)", () => { - expect(buildRequestBody({ model: "gpt_5_mini" }, messages)).toEqual({ - model: "gpt_5_mini", - messages, - }); - }); - - test("should include 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("should never emit 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); - } - }); }); // --------------------------------------------------------------------------- @@ -197,7 +132,7 @@ describe("Agent loop", () => { 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." }, transport); + const agent = createAgent({ model: "gpt_5_mini", system: "Be terse." }, openAIProvider(transport)); const result = await agent.run({ prompt: "Hi" }); expect(result.text).toBe("Hello there."); @@ -227,7 +162,7 @@ describe("Agent loop", () => { tools: { getWeather: { description: "weather", parameters: { type: "object" }, execute } }, maxSteps: 4, }, - transport + openAIProvider(transport) ); const result = await agent.run({ prompt: "weather in Haifa?" }); @@ -254,7 +189,7 @@ describe("Agent loop", () => { model: "m", tools: { boom: { description: "x", parameters: { type: "object" }, execute: async () => { throw new Error("kaboom"); } } }, }, - transport + openAIProvider(transport) ); const result = await agent.run({ prompt: "go" }); expect(result.text).toBe("recovered"); @@ -273,7 +208,7 @@ describe("Agent loop", () => { tools: { t: { description: "x", parameters: { type: "object" }, execute: async () => "ok" } }, maxSteps: 2, }, - transport + openAIProvider(transport) ); const result = await agent.run({ prompt: "loop" }); expect(result.finishReason).toBe("max_steps"); @@ -283,7 +218,7 @@ describe("Agent loop", () => { 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" }, transport); + const agent = createAgent({ model: "m" }, openAIProvider(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" }]); From c4e0ce1a3d3201794f909b2255e0aaf6ac593fe6 Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 12:01:43 +0300 Subject: [PATCH 26/29] refactor(agents): system as a neutral message role; faithful ChatMessage[] history mapping Co-Authored-By: Claude Sonnet 4.6 --- src/modules/agents/loop.ts | 35 +++++++++++++++++------ src/modules/agents/provider.ts | 12 ++++---- src/modules/agents/providers/openai.ts | 14 +++++---- tests/unit/agents-code.test.ts | 38 +++++++++++++++++++++++++ tests/unit/agents-provider.test.ts | 39 ++++++++++++++++++++++++-- 5 files changed, 116 insertions(+), 22 deletions(-) diff --git a/src/modules/agents/loop.ts b/src/modules/agents/loop.ts index 39ae792..7033408 100644 --- a/src/modules/agents/loop.ts +++ b/src/modules/agents/loop.ts @@ -3,14 +3,31 @@ import type { LanguageModel, ModelMessage } from "./provider.js"; const DEFAULT_MAX_STEPS = 8; +function safeParse(json: string | undefined | null): unknown { + try { return JSON.parse(json || "{}"); } catch { return {}; } +} + function inputToMessages(input: RunInput): ModelMessage[] { if ("messages" in input) { - // RunInput messages are the public ChatMessage[]; map to neutral user/assistant text. - return input.messages.map((m) => - m.role === "assistant" - ? { role: "assistant" as const, content: typeof m.content === "string" ? m.content : "" } - : { role: "user" as const, content: typeof m.content === "string" ? m.content : "" } - ); + // Map public ChatMessage[] 1:1 to neutral ModelMessage[] by role. + return input.messages.map((m): ModelMessage => { + if (m.role === "system") { + return { role: "system", content: typeof m.content === "string" ? m.content : "" }; + } + if (m.role === "user") { + return { role: "user", content: typeof m.content === "string" ? m.content : "" }; + } + if (m.role === "assistant") { + const toolCalls = m.tool_calls?.map((c) => ({ + id: c.id, + name: c.function.name, + args: safeParse(c.function.arguments), + })); + return { role: "assistant", content: m.content ?? undefined, toolCalls: toolCalls?.length ? toolCalls : undefined }; + } + // tool + return { role: "tool", toolCallId: (m as any).tool_call_id, result: typeof m.content === "string" ? m.content : "" }; + }); } return [{ role: "user", content: input.prompt }]; } @@ -26,14 +43,16 @@ export function createAgent(agentConfig: AgentConfig, model: LanguageModel): Age const agent: Agent = { async run(input: RunInput, options: RunOptions = {}): Promise { - const messages: ModelMessage[] = inputToMessages(input); + const messages: ModelMessage[] = [ + ...(agentConfig.system ? [{ role: "system" as const, content: agentConfig.system }] : []), + ...inputToMessages(input), + ]; const steps: Step[] = []; let last: Awaited> | null = null; for (let i = 0; i < maxSteps; i++) { last = await model.generate({ model: agentConfig.model, - system: agentConfig.system, messages, tools, temperature: agentConfig.temperature, diff --git a/src/modules/agents/provider.ts b/src/modules/agents/provider.ts index 224e3e7..ac5eb0e 100644 --- a/src/modules/agents/provider.ts +++ b/src/modules/agents/provider.ts @@ -4,15 +4,18 @@ import type { Tool, ToolChoice, JSONSchema, RunUsage } from "./agents.types.js"; export interface ModelToolCall { id: string; name: string; args: unknown } /** - * Neutral, provider-agnostic conversation message. `system` is NOT here — it is a - * first-class field on {@link GenerateRequest}. Content is a string for now; a - * parts-array variant can be added later for multimodal without breaking this union. + * Neutral, provider-agnostic conversation message. `system` is a message role in the + * array (the dominant pattern in Vercel AI SDK and LangChain.js); each adapter + * relocates it as needed (OpenAI keeps it as a system message; Anthropic lifts it to a + * top-level param). Content is a string for now; a parts-array variant can be added + * later for multimodal without breaking this union. * @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 }; + | { role: "tool"; toolCallId: string; toolName?: string; result: string }; /** Finish reasons normalized across providers. @internal */ export type FinishReason = "stop" | "length" | "tool-calls" | "content-filter" | "error" | "other"; @@ -20,7 +23,6 @@ export type FinishReason = "stop" | "length" | "tool-calls" | "content-filter" | /** A request to a language model. @internal */ export interface GenerateRequest { model: string; - system?: string; messages: ModelMessage[]; tools?: Record; temperature?: number; diff --git a/src/modules/agents/providers/openai.ts b/src/modules/agents/providers/openai.ts index 67d4abc..7a991c5 100644 --- a/src/modules/agents/providers/openai.ts +++ b/src/modules/agents/providers/openai.ts @@ -12,13 +12,15 @@ function serializeTools(tools?: Record): OpenAIToolDef[] | undefin return entries.map(([name, t]) => ({ type: "function", function: { name, description: t.description, parameters: t.parameters } })); } -/** Neutral messages (+ system) -> OpenAI chat messages. */ -function toOpenAIMessages(system: string | undefined, messages: ModelMessage[]): Record[] { +/** Neutral messages -> OpenAI chat messages. System role in the array is passed through. */ +function toOpenAIMessages(messages: ModelMessage[]): Record[] { const out: Record[] = []; - if (system) out.push({ role: "system", content: system }); for (const m of messages) { - if (m.role === "user") out.push({ role: "user", content: m.content }); - else if (m.role === "assistant") { + if (m.role === "system") { + out.push({ role: "system", content: m.content }); + } else if (m.role === "user") { + out.push({ role: "user", content: m.content }); + } else if (m.role === "assistant") { const msg: Record = { role: "assistant", content: m.content ?? null }; if (m.toolCalls?.length) { msg.tool_calls = m.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: JSON.stringify(c.args ?? {}) } })); @@ -33,7 +35,7 @@ function toOpenAIMessages(system: string | undefined, messages: ModelMessage[]): /** Build the OpenAI body using the same param whitelist as before (rejected params can never appear). */ function buildOpenAIBody(req: GenerateRequest): Record { - const body: Record = { model: req.model, messages: toOpenAIMessages(req.system, req.messages) }; + const body: Record = { model: req.model, messages: toOpenAIMessages(req.messages) }; if (req.temperature !== undefined) body.temperature = req.temperature; if (req.toolChoice !== undefined) body.tool_choice = req.toolChoice; if (req.responseFormat !== undefined) { diff --git a/tests/unit/agents-code.test.ts b/tests/unit/agents-code.test.ts index 5e178b0..019365c 100644 --- a/tests/unit/agents-code.test.ts +++ b/tests/unit/agents-code.test.ts @@ -223,6 +223,44 @@ describe("Agent loop", () => { 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" }, openAIProvider(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." }, openAIProvider(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); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/agents-provider.test.ts b/tests/unit/agents-provider.test.ts index 7d1b2ea..d39116d 100644 --- a/tests/unit/agents-provider.test.ts +++ b/tests/unit/agents-provider.test.ts @@ -18,12 +18,15 @@ describe("openAIProvider adapter", () => { return openAIProvider(createGatewayTransport({ serverUrl: "https://a.base44.app", getToken: () => "t" })); }; - test("system is sent as a system message; user/assistant/tool messages serialized to OpenAI shape", async () => { + 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", system: "Be terse.", - messages: [{ role: "user", content: "hi" }], + 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([ @@ -70,4 +73,34 @@ describe("openAIProvider adapter", () => { 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); + }); }); From bd675c8c9679b5e4d95800d348dd218668c162b1 Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 13:11:44 +0300 Subject: [PATCH 27/29] refactor(agents): rename provider to openai-compatible / chat-completions vocabulary Co-Authored-By: Claude Opus 4.8 (1M context) --- src/modules/agents/index.ts | 4 +-- src/modules/agents/provider.ts | 1 - .../{openai.ts => openai-compatible.ts} | 29 +++++++++++-------- tests/unit/agents-code.test.ts | 16 +++++----- tests/unit/agents-provider.test.ts | 6 ++-- 5 files changed, 30 insertions(+), 26 deletions(-) rename src/modules/agents/providers/{openai.ts => openai-compatible.ts} (71%) diff --git a/src/modules/agents/index.ts b/src/modules/agents/index.ts index 926cc48..dea8709 100644 --- a/src/modules/agents/index.ts +++ b/src/modules/agents/index.ts @@ -8,7 +8,7 @@ import { CreateConversationParams, } from "./agents.types.js"; import { createGatewayTransport } from "./gateway.js"; -import { openAIProvider } from "./providers/openai.js"; +import { openAICompatibleProvider } from "./providers/openai-compatible.js"; import { createAgent } from "./loop.js"; export function createAgentsModule({ @@ -19,7 +19,7 @@ export function createAgentsModule({ token, getToken, }: AgentsModuleConfig): AgentsModule { - const model = openAIProvider(createGatewayTransport({ + const model = openAICompatibleProvider(createGatewayTransport({ serverUrl: serverUrl ?? "", getToken: getToken ?? (() => token), })); diff --git a/src/modules/agents/provider.ts b/src/modules/agents/provider.ts index ac5eb0e..9ca8fc2 100644 --- a/src/modules/agents/provider.ts +++ b/src/modules/agents/provider.ts @@ -46,5 +46,4 @@ export interface GenerateResult { /** The provider seam. Adapters translate neutral <-> vendor wire. @internal */ export interface LanguageModel { generate(req: GenerateRequest): Promise; - // Phase 1B: stream(req: GenerateRequest): AsyncIterable; } diff --git a/src/modules/agents/providers/openai.ts b/src/modules/agents/providers/openai-compatible.ts similarity index 71% rename from src/modules/agents/providers/openai.ts rename to src/modules/agents/providers/openai-compatible.ts index 7a991c5..d1a7553 100644 --- a/src/modules/agents/providers/openai.ts +++ b/src/modules/agents/providers/openai-compatible.ts @@ -3,17 +3,17 @@ import type { GenerateRequest, GenerateResult, LanguageModel, ModelMessage, Mode interface GatewayTransport { complete(body: Record, opts?: { signal?: AbortSignal }): Promise } -interface OpenAIToolDef { type: "function"; function: { name: string; description: string; parameters: JSONSchema } } +interface ChatCompletionToolDef { type: "function"; function: { name: string; description: string; parameters: JSONSchema } } -function serializeTools(tools?: Record): OpenAIToolDef[] | undefined { +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, t]) => ({ type: "function", function: { name, description: t.description, parameters: t.parameters } })); } -/** Neutral messages -> OpenAI chat messages. System role in the array is passed through. */ -function toOpenAIMessages(messages: ModelMessage[]): Record[] { +/** Neutral messages -> Chat Completions messages. System role in the array is passed through. */ +function toChatMessages(messages: ModelMessage[]): Record[] { const out: Record[] = []; for (const m of messages) { if (m.role === "system") { @@ -33,9 +33,9 @@ function toOpenAIMessages(messages: ModelMessage[]): Record[] { return out; } -/** Build the OpenAI body using the same param whitelist as before (rejected params can never appear). */ -function buildOpenAIBody(req: GenerateRequest): Record { - const body: Record = { model: req.model, messages: toOpenAIMessages(req.messages) }; +/** 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) { @@ -55,7 +55,7 @@ function normalizeFinish(raw: string | undefined, hasToolCalls: boolean): Finish return "other"; } -function parseOpenAICompletion(raw: any): GenerateResult { +function parseChatCompletion(raw: any): GenerateResult { const choice = raw?.choices?.[0]; const message = choice?.message ?? {}; const toolCalls: ModelToolCall[] = (message.tool_calls ?? []).map((c: any) => { @@ -74,12 +74,17 @@ function parseOpenAICompletion(raw: any): GenerateResult { }; } -/** OpenAI-compatible adapter over the Base44 gateway transport. @internal */ -export function openAIProvider(transport: GatewayTransport): LanguageModel { +/** + * OpenAI-compatible provider: speaks the Chat Completions wire format over the Base44 + * gateway transport. Most vendors (and the gateway) expose this protocol; a future + * `openai-responses` or native `anthropic` provider would sit beside this file. + * @internal + */ +export function openAICompatibleProvider(transport: GatewayTransport): LanguageModel { return { async generate(req: GenerateRequest): Promise { - const raw = await transport.complete(buildOpenAIBody(req), { signal: req.signal }); - return parseOpenAICompletion(raw); + const raw = await transport.complete(buildChatCompletionsBody(req), { signal: req.signal }); + return parseChatCompletion(raw); }, }; } diff --git a/tests/unit/agents-code.test.ts b/tests/unit/agents-code.test.ts index 019365c..2dbe6c3 100644 --- a/tests/unit/agents-code.test.ts +++ b/tests/unit/agents-code.test.ts @@ -5,7 +5,7 @@ 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 { openAIProvider } from "../../src/modules/agents/providers/openai.ts"; +import { openAICompatibleProvider } from "../../src/modules/agents/providers/openai-compatible.ts"; const config = { serverUrl: "https://app-1.base44.app", @@ -132,7 +132,7 @@ describe("Agent loop", () => { 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." }, openAIProvider(transport)); + 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."); @@ -162,7 +162,7 @@ describe("Agent loop", () => { tools: { getWeather: { description: "weather", parameters: { type: "object" }, execute } }, maxSteps: 4, }, - openAIProvider(transport) + openAICompatibleProvider(transport) ); const result = await agent.run({ prompt: "weather in Haifa?" }); @@ -189,7 +189,7 @@ describe("Agent loop", () => { model: "m", tools: { boom: { description: "x", parameters: { type: "object" }, execute: async () => { throw new Error("kaboom"); } } }, }, - openAIProvider(transport) + openAICompatibleProvider(transport) ); const result = await agent.run({ prompt: "go" }); expect(result.text).toBe("recovered"); @@ -208,7 +208,7 @@ describe("Agent loop", () => { tools: { t: { description: "x", parameters: { type: "object" }, execute: async () => "ok" } }, maxSteps: 2, }, - openAIProvider(transport) + openAICompatibleProvider(transport) ); const result = await agent.run({ prompt: "loop" }); expect(result.finishReason).toBe("max_steps"); @@ -218,7 +218,7 @@ describe("Agent loop", () => { 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" }, openAIProvider(transport)); + 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" }]); @@ -227,7 +227,7 @@ describe("Agent loop", () => { 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" }, openAIProvider(transport)); + const agent = createAgent({ model: "m" }, openAICompatibleProvider(transport)); await agent.run({ messages: [ { role: "system", content: "You are a pirate." }, @@ -254,7 +254,7 @@ describe("Agent loop", () => { 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." }, openAIProvider(transport)); + 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." }); diff --git a/tests/unit/agents-provider.test.ts b/tests/unit/agents-provider.test.ts index d39116d..1e2c289 100644 --- a/tests/unit/agents-provider.test.ts +++ b/tests/unit/agents-provider.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, afterEach, vi } from "vitest"; -import { openAIProvider } from "../../src/modules/agents/providers/openai.ts"; +import { openAICompatibleProvider } from "../../src/modules/agents/providers/openai-compatible.ts"; const transportFor = (body: object) => { const fetchMock = vi.fn().mockResolvedValue( @@ -9,13 +9,13 @@ const transportFor = (body: object) => { return fetchMock; }; -describe("openAIProvider adapter", () => { +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 openAIProvider(createGatewayTransport({ serverUrl: "https://a.base44.app", getToken: () => "t" })); + return openAICompatibleProvider(createGatewayTransport({ serverUrl: "https://a.base44.app", getToken: () => "t" })); }; test("system role message is serialized as a leading OpenAI system message", async () => { From 98f2544ddc1552a5e330d280f518508579967f83 Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 17:10:17 +0300 Subject: [PATCH 28/29] feat(agents): aggregate usage across steps (usage + totalUsage) and clarify loop internals - RunUsage: rename promptTokens/completionTokens -> inputTokens/outputTokens - RunResult: add totalUsage (summed across all model calls); usage is the final call - Step: carry per-call usage so totalUsage is auditable - openai-compatible adapter maps prompt_tokens/completion_tokens/base44_credits accordingly - readability: clearer names (modelResult, matchedTool, totalUsage, sumUsage) and multi-line returns in loop.ts / openai-compatible.ts Co-Authored-By: Claude Opus 4.8 (1M context) --- src/modules/agents/agents.types.ts | 10 +- src/modules/agents/loop.ts | 137 ++++++++++++------ .../agents/providers/openai-compatible.ts | 54 ++++--- tests/types/agents-code.types.ts | 7 +- tests/unit/agents-code.test.ts | 24 ++- tests/unit/agents-provider.test.ts | 2 +- 6 files changed, 165 insertions(+), 69 deletions(-) diff --git a/src/modules/agents/agents.types.ts b/src/modules/agents/agents.types.ts index 250e9e9..888c983 100644 --- a/src/modules/agents/agents.types.ts +++ b/src/modules/agents/agents.types.ts @@ -45,12 +45,14 @@ export interface Step { 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 run. `credits` is the Base44 gateway's `base44_credits`. */ +/** Token/credit usage for a single model call or a sum across calls. `credits` is the Base44 gateway's `base44_credits`. */ export interface RunUsage { - promptTokens?: number; - completionTokens?: number; + inputTokens?: number; + outputTokens?: number; totalTokens?: number; credits?: number; } @@ -65,6 +67,8 @@ export interface RunResult { finishReason: string; /** 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; } diff --git a/src/modules/agents/loop.ts b/src/modules/agents/loop.ts index 7033408..84c4932 100644 --- a/src/modules/agents/loop.ts +++ b/src/modules/agents/loop.ts @@ -1,39 +1,60 @@ -import type { Agent, AgentConfig, RunInput, RunOptions, RunResult, Step, Tool } from "./agents.types.js"; +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 safeParse(json: string | undefined | null): unknown { - try { return JSON.parse(json || "{}"); } catch { return {}; } +function safeParseJson(json: string | undefined | null): unknown { + try { + return JSON.parse(json || "{}"); + } catch { + return {}; + } } function inputToMessages(input: RunInput): ModelMessage[] { - if ("messages" in input) { - // Map public ChatMessage[] 1:1 to neutral ModelMessage[] by role. - return input.messages.map((m): ModelMessage => { - if (m.role === "system") { - return { role: "system", content: typeof m.content === "string" ? m.content : "" }; - } - if (m.role === "user") { - return { role: "user", content: typeof m.content === "string" ? m.content : "" }; - } - if (m.role === "assistant") { - const toolCalls = m.tool_calls?.map((c) => ({ - id: c.id, - name: c.function.name, - args: safeParse(c.function.arguments), - })); - return { role: "assistant", content: m.content ?? undefined, toolCalls: toolCalls?.length ? toolCalls : undefined }; - } - // tool - return { role: "tool", toolCallId: (m as any).tool_call_id, result: typeof m.content === "string" ? m.content : "" }; - }); + if (!("messages" in input)) { + return [{ role: "user", content: input.prompt }]; } - 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 stringifyResult(out: unknown): string { - return typeof out === "string" ? out : JSON.stringify(out); +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 */ @@ -48,10 +69,12 @@ export function createAgent(agentConfig: AgentConfig, model: LanguageModel): Age ...inputToMessages(input), ]; const steps: Step[] = []; - let last: Awaited> | null = null; + 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 i = 0; i < maxSteps; i++) { - last = await model.generate({ + for (let step = 0; step < maxSteps; step++) { + modelResult = await model.generate({ model: agentConfig.model, messages, tools, @@ -60,47 +83,73 @@ export function createAgent(agentConfig: AgentConfig, model: LanguageModel): Age responseFormat: agentConfig.responseFormat, signal: options.abortSignal, }); + totalUsage = sumUsage(totalUsage, modelResult.usage ?? {}); messages.push({ role: "assistant", - content: last.text || undefined, - toolCalls: last.toolCalls.length ? last.toolCalls : undefined, + content: modelResult.text || undefined, + toolCalls: modelResult.toolCalls.length ? modelResult.toolCalls : undefined, }); - if (last.toolCalls.length === 0) { - return { text: last.text, steps, finishReason: last.finishReason, usage: last.usage, raw: last.raw }; + // 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 last.toolCalls) { - const t = tools?.[call.name]; + for (const call of modelResult.toolCalls) { + const matchedTool = tools?.[call.name]; let resultContent: string; - if (!t) { + if (!matchedTool) { resultContent = `Error: tool "${call.name}" is not available.`; } else { try { - resultContent = stringifyResult(await t.execute(call.args)); - } catch (e: unknown) { - const err = e as { message?: string }; - resultContent = `Error: ${err?.message ?? String(e)}`; + 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 }); + steps.push({ toolResults, usage: modelResult.usage }); } - return { text: last?.text ?? "", steps, finishReason: "max_steps", usage: last?.usage ?? {}, raw: last?.raw ?? null }; + // 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 }) => (await agent.run({ prompt: args.prompt })).text, + 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/providers/openai-compatible.ts b/src/modules/agents/providers/openai-compatible.ts index d1a7553..9212449 100644 --- a/src/modules/agents/providers/openai-compatible.ts +++ b/src/modules/agents/providers/openai-compatible.ts @@ -9,28 +9,35 @@ function serializeTools(tools?: Record): ChatCompletionToolDef[] | 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 } })); + 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 out: Record[] = []; - for (const m of messages) { - if (m.role === "system") { - out.push({ role: "system", content: m.content }); - } else if (m.role === "user") { - out.push({ role: "user", content: m.content }); - } else if (m.role === "assistant") { - const msg: Record = { role: "assistant", content: m.content ?? null }; - if (m.toolCalls?.length) { - msg.tool_calls = m.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: JSON.stringify(c.args ?? {}) } })); + 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 ?? {}) }, + })); } - out.push(msg); + chatMessages.push(assistantMessage); } else { - out.push({ role: "tool", tool_call_id: m.toolCallId, content: m.result }); + chatMessages.push({ role: "tool", tool_call_id: message.toolCallId, content: message.result }); } } - return out; + return chatMessages; } /** Build the Chat Completions body using the param whitelist (rejected params can never appear). */ @@ -58,13 +65,22 @@ function normalizeFinish(raw: string | undefined, hasToolCalls: boolean): Finish function parseChatCompletion(raw: any): GenerateResult { const choice = raw?.choices?.[0]; const message = choice?.message ?? {}; - const toolCalls: ModelToolCall[] = (message.tool_calls ?? []).map((c: any) => { + const toolCalls: ModelToolCall[] = (message.tool_calls ?? []).map((call: any) => { let args: unknown = {}; - try { args = JSON.parse(c.function?.arguments || "{}"); } catch { args = {}; } - return { id: c.id, name: c.function?.name, args }; + try { + args = JSON.parse(call.function?.arguments || "{}"); + } catch { + args = {}; + } + return { id: call.id, name: call.function?.name, args }; }); - const u = raw?.usage ?? {}; - const usage: RunUsage = { promptTokens: u.prompt_tokens, completionTokens: u.completion_tokens, totalTokens: u.total_tokens, credits: u.base44_credits }; + 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, diff --git a/tests/types/agents-code.types.ts b/tests/types/agents-code.types.ts index 6321c19..17a82be 100644 --- a/tests/types/agents-code.types.ts +++ b/tests/types/agents-code.types.ts @@ -1,4 +1,4 @@ -import type { RunInput, ToolChoice, Agent, ChatMessage, AgentsModule } from "../../src/index.js"; +import type { RunInput, ToolChoice, Agent, ChatMessage, AgentsModule, RunResult, RunUsage } from "../../src/index.js"; // --------------------------------------------------------------------------- // RunInput — union of { prompt: string } | { messages: ChatMessage[] } @@ -70,3 +70,8 @@ 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; diff --git a/tests/unit/agents-code.test.ts b/tests/unit/agents-code.test.ts index 2dbe6c3..e658515 100644 --- a/tests/unit/agents-code.test.ts +++ b/tests/unit/agents-code.test.ts @@ -137,7 +137,8 @@ describe("Agent loop", () => { 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.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); @@ -261,6 +262,27 @@ describe("Agent loop", () => { 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 }); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/agents-provider.test.ts b/tests/unit/agents-provider.test.ts index 1e2c289..f01baea 100644 --- a/tests/unit/agents-provider.test.ts +++ b/tests/unit/agents-provider.test.ts @@ -49,7 +49,7 @@ describe("openAICompatibleProvider adapter", () => { 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({ promptTokens: 3, completionTokens: 2, totalTokens: 5, credits: 1 }); + expect(r.usage).toEqual({ inputTokens: 3, outputTokens: 2, totalTokens: 5, credits: 1 }); expect(r.text).toBe(""); }); From f1e7fd1f4ee189620685a2dbf85be03671854bf0 Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 24 Jun 2026 18:23:29 +0300 Subject: [PATCH 29/29] feat(agents): add entities..asTool (per-op tools, read-only default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read_/create_/update_/delete_ tools built from operations allow-list - read uses a filter (query/sort/limit/skip/fields); create/update/delete wired to the entity handler - read-only default; writes opt-in via operations - params are deferred-schema (open object for create/update) — schema-derived params are a follow-up - drop internal-discussion/roadmap notes from agents-module comments Co-Authored-By: Claude Opus 4.8 (1M context) --- src/modules/agents/gateway.ts | 5 +- src/modules/agents/provider.ts | 7 +- .../agents/providers/openai-compatible.ts | 3 +- src/modules/entities.ts | 63 ++++++++++++++++- src/modules/entities.types.ts | 20 ++++++ tests/types/entities-as-tool.types.ts | 14 ++++ tests/unit/entities-as-tool.test.ts | 68 +++++++++++++++++++ 7 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 tests/types/entities-as-tool.types.ts create mode 100644 tests/unit/entities-as-tool.test.ts diff --git a/src/modules/agents/gateway.ts b/src/modules/agents/gateway.ts index 4ed3933..1393156 100644 --- a/src/modules/agents/gateway.ts +++ b/src/modules/agents/gateway.ts @@ -24,9 +24,8 @@ export function resolveConnection(config: GatewayConfig): { } /** - * 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()`. + * Creates the gateway transport — owns the single HTTP call to the OpenAI-compatible + * `/chat/completions` endpoint. * @internal */ export function createGatewayTransport(config: GatewayConfig) { diff --git a/src/modules/agents/provider.ts b/src/modules/agents/provider.ts index 9ca8fc2..ddf1d6a 100644 --- a/src/modules/agents/provider.ts +++ b/src/modules/agents/provider.ts @@ -4,11 +4,8 @@ import type { Tool, ToolChoice, JSONSchema, RunUsage } from "./agents.types.js"; export interface ModelToolCall { id: string; name: string; args: unknown } /** - * Neutral, provider-agnostic conversation message. `system` is a message role in the - * array (the dominant pattern in Vercel AI SDK and LangChain.js); each adapter - * relocates it as needed (OpenAI keeps it as a system message; Anthropic lifts it to a - * top-level param). Content is a string for now; a parts-array variant can be added - * later for multimodal without breaking this union. + * 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 = diff --git a/src/modules/agents/providers/openai-compatible.ts b/src/modules/agents/providers/openai-compatible.ts index 9212449..eccdaf2 100644 --- a/src/modules/agents/providers/openai-compatible.ts +++ b/src/modules/agents/providers/openai-compatible.ts @@ -92,8 +92,7 @@ function parseChatCompletion(raw: any): GenerateResult { /** * OpenAI-compatible provider: speaks the Chat Completions wire format over the Base44 - * gateway transport. Most vendors (and the gateway) expose this protocol; a future - * `openai-responses` or native `anthropic` provider would sit beside this file. + * gateway transport. * @internal */ export function openAICompatibleProvider(transport: GatewayTransport): LanguageModel { diff --git a/src/modules/entities.ts b/src/modules/entities.ts index 1eaaf28..048827d 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 c5854dd..4331664 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -696,6 +696,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 -> {@linkcode Tool}. + * + * @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/tests/types/entities-as-tool.types.ts b/tests/types/entities-as-tool.types.ts new file mode 100644 index 0000000..390ccf0 --- /dev/null +++ b/tests/types/entities-as-tool.types.ts @@ -0,0 +1,14 @@ +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"] }); diff --git a/tests/unit/entities-as-tool.test.ts b/tests/unit/entities-as-tool.test.ts new file mode 100644 index 0000000..657ea6f --- /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(); + }); +});