Skip to content

Commit 47bf570

Browse files
bjoaquincpre-commit-ci[bot]mscolnick
authored
feat: Create frontend tools framework and integrate to backend ai system (#6609)
## 📝 Summary <!-- Provide a concise summary of what this pull request is addressing. If this PR fixes any issues, list them here by number (e.g., Fixes #123). --> Create frontend tools framework to manage and send tools to backend via ai body. Update openapi to include ToolDefinition in body. Remove any frontend tool handing from ToolManager. <img width="504" height="951" alt="Screenshot 2025-09-29 at 9 12 24 PM" src="https://github.com/user-attachments/assets/6ddf33e6-0382-416e-8d2b-98c6b86a9bfa" /> ## 🔍 Description of Changes <!-- Detail the specific changes made in this pull request. Explain the problem addressed and how it was resolved. If applicable, provide before and after comparisons, screenshots, or any relevant details to help reviewers understand the changes easily. --> - Create frontend tool registry to handle registering and invoking tools from the frontend in registry.ts - Add BaseTool to handle frontend tool shape validation and centralize logic in base.ts - Handle zod v4 collision in tsconfig.json - Add sample tools as example of how to create a frontend tool (maybe remove before merge?) - Send back frontend tools via body in chat-panel.tsx - Update invokeAiTool to handle frontend tools if in list in chat-utils.ts - Add ToolDefinition to ChatRequest body and OpenAPI schemas - Inject tools through stream_completion if tools is avaliable in ai.py and providers.py - Remove all left over frontend tool logic and handling in tool_manager.py ## 📋 Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [x] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [ ] I have added tests for the changes made. - [x] I have run the code and verified that it works as expected. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduce a frontend tools framework and pass tool schemas through chat requests to providers, while removing frontend-tool handling from the backend and updating OpenAPI/types. > > - **Frontend**: > - **Tools Framework**: Add `AiTool` interface (`base.ts`), `FrontendToolRegistry` with zod validation and memoized schemas (`registry.ts`), and a sample tool (`sample-tool.ts`); add tests for registry behavior. > - **Chat Integration**: Send `FRONTEND_TOOL_REGISTRY.getToolSchemas()` in chat request body and handle frontend tool calls client-side before falling back to backend (`chat-panel.tsx`, `chat-utils.ts`). > - **Config**: Pin `zod` path in `tsconfig.json`. > - **Backend**: > - **Providers**: Extend `stream_completion` to accept `additional_tools` and merge with configured tools for OpenAI, Anthropic, Google, and Bedrock (`providers.py`). > - **Endpoints**: Pass `body.tools` to providers in `POST /api/ai/chat`; no tools for completion/inline completion (`ai.py`). > - **ToolManager**: Drop frontend tool registration/lookup; warn on frontend source requests; simplify init log; tighten error messages (`tool_manager.py`). > - **API/OpenAPI**: > - Add `ToolDefinition` schema and include optional `tools` in `ChatRequest`; regenerate TS types (`api.yaml`, `src/api.ts`). > - Include `ToolDefinition` in dev OpenAPI generation (`commands.py`). > - **Tests**: > - Update provider tests to new `stream_completion` signature (`test_providers.py`). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d7836e8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Myles Scolnick <[email protected]>
1 parent 29db9c0 commit 47bf570

File tree

15 files changed

+328
-36
lines changed

15 files changed

+328
-36
lines changed

frontend/src/components/chat/chat-panel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
type ChatId,
4545
chatStateAtom,
4646
} from "@/core/ai/state";
47+
import { FRONTEND_TOOL_REGISTRY } from "@/core/ai/tools/registry";
4748
import { aiAtom, aiEnabledAtom } from "@/core/config/config";
4849
import { DEFAULT_AI_MODEL } from "@/core/config/config-schema";
4950
import { FeatureFlagged } from "@/core/config/feature-flag";
@@ -585,6 +586,7 @@ const ChatPanelBody = () => {
585586

586587
return {
587588
body: {
589+
tools: FRONTEND_TOOL_REGISTRY.getToolSchemas(),
588590
...options,
589591
...completionBody,
590592
},

frontend/src/components/chat/chat-utils.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import type { components } from "@marimo-team/marimo-api";
44
import type { FileUIPart, ToolUIPart, UIMessage } from "ai";
5+
import { FRONTEND_TOOL_REGISTRY } from "@/core/ai/tools/registry";
56
import type {
67
InvokeAiToolRequest,
78
InvokeAiToolResponse,
@@ -117,15 +118,29 @@ export async function handleToolCall({
117118
};
118119
}) {
119120
try {
120-
const response = await invokeAiTool({
121-
toolName: toolCall.toolName,
122-
arguments: toolCall.input,
123-
});
124-
addToolResult({
125-
tool: toolCall.toolName,
126-
toolCallId: toolCall.toolCallId,
127-
output: response.result || response.error,
128-
});
121+
if (FRONTEND_TOOL_REGISTRY.has(toolCall.toolName)) {
122+
// Invoke the frontend tool
123+
const response = await FRONTEND_TOOL_REGISTRY.invoke(
124+
toolCall.toolName,
125+
toolCall.input,
126+
);
127+
addToolResult({
128+
tool: toolCall.toolName,
129+
toolCallId: toolCall.toolCallId,
130+
output: response,
131+
});
132+
} else {
133+
// Invoke the backend/mcp tool
134+
const response = await invokeAiTool({
135+
toolName: toolCall.toolName,
136+
arguments: toolCall.input,
137+
});
138+
addToolResult({
139+
tool: toolCall.toolName,
140+
toolCallId: toolCall.toolCallId,
141+
output: response.result || response.error,
142+
});
143+
}
129144
} catch (error) {
130145
Logger.error("Tool call failed:", error);
131146
addToolResult({
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
3+
import { describe, expect, it } from "vitest";
4+
import { FrontendToolRegistry } from "../registry";
5+
import { TestFrontendTool } from "../sample-tool";
6+
7+
describe("FrontendToolRegistry", () => {
8+
it("registers tools via constructor and supports has()", () => {
9+
const registry = new FrontendToolRegistry([new TestFrontendTool()]);
10+
expect(registry.has("test_frontend_tool")).toBe(true);
11+
expect(registry.has("nonexistent_tool" as string)).toBe(false);
12+
});
13+
14+
it("invokes a tool with valid args and validates input/output", async () => {
15+
const registry = new FrontendToolRegistry([new TestFrontendTool()]);
16+
const ok = await registry.invoke("test_frontend_tool", { name: "Alice" });
17+
expect(ok).toEqual({ message: "Hello: Alice" });
18+
});
19+
20+
it("returns a structured error on invalid args", async () => {
21+
const registry = new FrontendToolRegistry([new TestFrontendTool()]);
22+
const err = (await registry.invoke("test_frontend_tool", {})) as Record<
23+
string,
24+
unknown
25+
>;
26+
expect(err.status).toBe("error");
27+
expect(err.code).toBe("TOOL_ERROR");
28+
});
29+
30+
it("returns tool schemas with expected shape and memoizes the result", () => {
31+
const registry = new FrontendToolRegistry([new TestFrontendTool()]);
32+
33+
const schemas1 = registry.getToolSchemas();
34+
expect(Array.isArray(schemas1)).toBe(true);
35+
expect(schemas1.length).toBe(1);
36+
37+
const def = schemas1[0];
38+
expect(def.name).toBe("test_frontend_tool");
39+
expect(def.source).toBe("frontend");
40+
expect(def.mode).toEqual(["ask"]);
41+
expect(typeof def.description).toBe("string");
42+
43+
const params = def.parameters as Record<string, unknown>;
44+
expect(params && typeof params === "object").toBe(true);
45+
const properties = (params as { properties?: Record<string, unknown> })
46+
.properties;
47+
expect(properties && typeof properties === "object").toBe(true);
48+
expect("name" in (properties ?? {})).toBe(true);
49+
50+
const schemas2 = registry.getToolSchemas();
51+
expect(schemas2).toBe(schemas1);
52+
});
53+
});

frontend/src/core/ai/tools/base.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
3+
import type { z } from "zod";
4+
import type { CopilotMode } from "./registry";
5+
6+
/**
7+
* Contract for a frontend tool.
8+
*
9+
* Implementations can be plain objects or classes. The registry consumes this
10+
* interface without caring about the underlying implementation.
11+
*/
12+
export interface AiTool<TIn, TOut> {
13+
name: string;
14+
description: string;
15+
schema: z.ZodType<TIn>;
16+
outputSchema: z.ZodType<TOut>;
17+
mode: CopilotMode[];
18+
handler: (args: TIn) => TOut | Promise<TOut>;
19+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
3+
import type { components } from "@marimo-team/marimo-api";
4+
import { Memoize } from "typescript-memoize";
5+
import { type ZodObject, z } from "zod";
6+
import type { AiTool } from "./base";
7+
import { TestFrontendTool } from "./sample-tool";
8+
9+
export type AnyZodObject = ZodObject<z.ZodRawShape>;
10+
11+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
12+
type StoredTool = AiTool<any, any>;
13+
14+
/** should be the same as marimo/_config/config.py > CopilotMode */
15+
16+
type ToolDefinition = components["schemas"]["ToolDefinition"];
17+
export type CopilotMode = ToolDefinition["mode"][number];
18+
19+
export interface FrontendToolDefinition extends ToolDefinition {
20+
source: "frontend";
21+
}
22+
23+
export class FrontendToolRegistry {
24+
/** All registered tools */
25+
private tools = new Map<string, StoredTool>();
26+
27+
constructor(tools: StoredTool[] = []) {
28+
this.tools = new Map(tools.map((tool) => [tool.name, tool]));
29+
}
30+
31+
has(toolName: string) {
32+
return this.tools.has(toolName);
33+
}
34+
35+
private getToolOrThrow(toolName: string): StoredTool {
36+
const tool = this.tools.get(toolName);
37+
if (!tool) {
38+
throw new Error(`Tool ${toolName} not found`);
39+
}
40+
return tool;
41+
}
42+
43+
async invoke<TName extends string>(
44+
toolName: TName,
45+
rawArgs: unknown,
46+
): Promise<unknown> {
47+
const tool = this.getToolOrThrow(toolName);
48+
const handler = tool.handler;
49+
const inputSchema = tool.schema;
50+
const outputSchema = tool.outputSchema;
51+
52+
try {
53+
// Parse input args
54+
const inputResponse = await inputSchema.safeParseAsync(rawArgs);
55+
if (inputResponse.error) {
56+
const strError = z.prettifyError(inputResponse.error);
57+
throw new Error(`Tool ${toolName} returned invalid input: ${strError}`);
58+
}
59+
const args = inputResponse.data;
60+
61+
// Call the handler
62+
const rawOutput = await handler(args);
63+
64+
// Parse output
65+
const response = await outputSchema.safeParseAsync(rawOutput);
66+
if (response.error) {
67+
const strError = z.prettifyError(response.error);
68+
throw new Error(
69+
`Tool ${toolName} returned invalid output: ${strError}`,
70+
);
71+
}
72+
const output = response.data;
73+
return output;
74+
} catch (error) {
75+
return {
76+
status: "error",
77+
code: "TOOL_ERROR",
78+
message: error instanceof Error ? error.message : String(error),
79+
suggestedFix: "Try again with valid arguments.",
80+
meta: {
81+
args: rawArgs,
82+
},
83+
};
84+
}
85+
}
86+
87+
@Memoize()
88+
getToolSchemas(): FrontendToolDefinition[] {
89+
return [...this.tools.values()].map((tool) => ({
90+
name: tool.name,
91+
description: tool.description,
92+
parameters: z.toJSONSchema(tool.schema),
93+
source: "frontend",
94+
mode: tool.mode,
95+
}));
96+
}
97+
}
98+
99+
export const FRONTEND_TOOL_REGISTRY = new FrontendToolRegistry([
100+
...(import.meta.env.DEV ? [new TestFrontendTool()] : []),
101+
// ADD MORE TOOLS HERE
102+
]);
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
3+
import { z } from "zod";
4+
import type { AiTool } from "./base";
5+
import type { CopilotMode } from "./registry";
6+
7+
const description = `
8+
Test frontend tool that returns a greeting message.
9+
10+
Args:
11+
- name (string): The name to include in the greeting.
12+
13+
Returns:
14+
- { message: string } — The greeting message, e.g., "Hello: Alice".
15+
`;
16+
17+
interface Input {
18+
name: string;
19+
}
20+
21+
interface Output {
22+
message: string;
23+
}
24+
25+
/** A sample frontend tool that returns "hello world" */
26+
export class TestFrontendTool implements AiTool<Input, Output> {
27+
readonly name = "test_frontend_tool";
28+
readonly description = description;
29+
readonly schema = z.object({ name: z.string() });
30+
readonly outputSchema = z.object({ message: z.string() });
31+
readonly mode: CopilotMode[] = ["ask"];
32+
33+
async handler({ name }: Input) {
34+
return { message: `Hello: ${name}` };
35+
}
36+
}

frontend/tsconfig.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
/* Style */
2424
"allowJs": true /* needed for storybook */,
2525
"paths": {
26-
"@/*": ["./src/*"]
26+
"@/*": ["./src/*"],
27+
/* Pin zod to root (v4); v3 exists via @modelcontextprotocol/sdk */
28+
"zod": ["./node_modules/zod"]
2729
},
2830
"forceConsistentCasingInFileNames": true,
2931
"useDefineForClassFields": true,

marimo/_cli/development/commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def _generate_server_api_schema() -> dict[str, Any]:
4949
from marimo._messaging.mimetypes import KnownMimeType
5050
from marimo._plugins.core.web_component import JSONType
5151
from marimo._runtime.packages.package_manager import PackageDescription
52+
from marimo._server.ai.tools.types import ToolDefinition
5253
from marimo._server.api.router import build_routes
5354
from marimo._utils.dataclass_to_openapi import (
5455
PythonTypeToOpenAPI,
@@ -136,6 +137,7 @@ def _generate_server_api_schema() -> dict[str, Any]:
136137
ops.MessageOperation,
137138
# ai
138139
ChatMessage,
140+
ToolDefinition,
139141
]
140142

141143
# dataclass components used in requests/responses

0 commit comments

Comments
 (0)