Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

### Fixed

- Agent files accept server route tools (`{ "tool_name", "route_path", "method", "description" }`), the tool type that lets an in-app agent call one of a full-stack app's own server routes. `base44 agents pull` wrote them, but every later command that reads the project — `agents push`, `deploy`, and most others — refused the file at `tool_configs[N]`. `method` defaults to `post` and `description` to `agent server route`, as on the server.
- `base44 link --app-id` links directly instead of first listing your apps to confirm the ID. The listing only covered the 50 most recently updated apps, and a workspace API key (`BASE44_API_KEY`) is refused on it, so `link --app-id` failed in CI. A wrong ID now fails on the first request that addresses the app.
- `base44 deploy --git-hash` (and `site deploy --publish`) no longer fails after a successful publish when the app's URL can't be looked up, as happened with a workspace API key (`BASE44_API_KEY`). It reports the deploy and leaves out the URL.
- An app id passed with `--app-id` or `BASE44_APP_ID` no longer discards the project the command runs in. Commands that need a project — `site dev`, `dev`, `connectors pull`/`push` — refused as if there were none. A checkout with `base44/config.jsonc` but no `.app.jsonc`, such as a hosted sandbox, can now run them.
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/core/resources/agent/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,33 @@ const BackendFunctionToolConfigSchema = z.object({
description: z.string().default("agent backend function"),
});

// A tool backed by one of a full-stack app's own server routes. route_path
// mirrors the backend's validated_route_path: an app-relative path of RFC 3986
// path characters, so a leading `//` (another host), a backslash, `?` and `#`
// are refused.
const ServerRouteToolConfigSchema = z.object({
tool_name: z
.string()
.regex(
/^[A-Za-z0-9_-]{1,64}$/,
"tool_name must be 1-64 letters, digits, '_' or '-'",
),
route_path: z
.string()
.trim()
.max(2000)
.regex(
/^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/,
"route_path must be an app-relative route like '/api/orders'",
),
method: z.enum(["get", "post", "put", "patch", "delete"]).default("post"),
description: z.string().default("agent server route"),
});

const ToolConfigSchema = z.union([
EntityToolConfigSchema,
BackendFunctionToolConfigSchema,
ServerRouteToolConfigSchema,
]);

const MemoryConfigSchema = z.object({
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/tests/cli/agents_pull.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,43 @@ describe("agents pull command", () => {
expect(parsed.description).toBe("Updated description");
expect(parsed.instructions).toBe("Updated instructions");
});

it("pushes a pulled server route tool back unchanged", async () => {
const remoteAgent = {
name: "time_agent",
description: "Tells the time",
instructions: "Use the route",
tool_configs: [
{ entity_name: "Task", allowed_operations: ["read"] },
{
tool_name: "convert_time",
route_path: "/api/zuri/convert-time",
method: "post",
description: "Converts a time between zones",
},
],
selected_skill_names: [],
};
await t.givenLoggedInWithProject(fixture("basic"));
t.api.mockAgentsFetch({ items: [remoteAgent], total: 1 });
let pushedBody: unknown;
t.api.mockRoute(
"PUT",
`/api/apps/${t.api.appId}/agent-configs`,
(req, res) => {
pushedBody = req.body;
res
.status(200)
.json({ created: [], updated: ["time_agent"], deleted: [] });
},
);

const pull = await t.run("agents", "pull");
const push = await t.run("agents", "push", "--yes");

t.expectResult(pull).toSucceed();
t.expectResult(push).toSucceed();
t.expectResult(push).toContain("Updated: time_agent");
expect(pushedBody).toEqual([remoteAgent]);
});
});
55 changes: 55 additions & 0 deletions packages/cli/tests/core/agents-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,58 @@ describe("AgentConfigSchema memory_config", () => {
expect(parsed.selected_skill_names).toEqual([]);
});
});

describe("AgentConfigSchema server route tools", () => {
const routeTool = {
tool_name: "convert_time",
route_path: "/api/zuri/convert-time",
method: "get",
description: "Converts a time between zones",
};

it("round-trips a route tool unchanged", () => {
const parsed = AgentConfigSchema.parse({
...baseAgent,
tool_configs: [routeTool],
});

expect(parsed.tool_configs).toEqual([routeTool]);
});

it("fills backend-matching defaults when method and description are omitted", () => {
const parsed = AgentConfigSchema.parse({
...baseAgent,
tool_configs: [{ tool_name: "sync", route_path: "/api/sync" }],
});

expect(parsed.tool_configs).toEqual([
{
tool_name: "sync",
route_path: "/api/sync",
method: "post",
description: "agent server route",
},
]);
});

it.each([
["a tool_name with a space", { tool_name: "convert time" }],
["a tool_name over 64 characters", { tool_name: "a".repeat(65) }],
["a blank route_path", { route_path: " " }],
["a route_path starting with //", { route_path: "//evil.example/x" }],
["a route_path that is not app-relative", { route_path: "api/x" }],
["a route_path with a query string", { route_path: "/api/x?y=1" }],
[
"a route_path over 2000 characters",
{ route_path: `/${"a".repeat(2000)}` },
],
["a method the backend does not allow", { method: "head" }],
])("rejects %s", (_label, override) => {
const result = AgentConfigSchema.safeParse({
...baseAgent,
tool_configs: [{ ...routeTool, ...override }],
});

expect(result.success).toBe(false);
});
});
Loading