Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f3c3410
Added sending tools from frontend to the backend
bjoaquinc Sep 29, 2025
c8de49c
Add ToolDefinition type for openapi
bjoaquinc Sep 29, 2025
a9e3fed
Add Frontend tool registry, base, and sample tool
bjoaquinc Sep 29, 2025
a2ba9f3
Send back tools with body to backend
bjoaquinc Sep 29, 2025
3d76bff
Add handler for invoking frontend tool if name is in list
bjoaquinc Sep 29, 2025
9394987
Update open api and types
bjoaquinc Sep 29, 2025
0024d42
Merge branch 'marimo-team:main' into frontend-framework
bjoaquinc Sep 29, 2025
b9337dd
Remove old comment
bjoaquinc Sep 29, 2025
2da93bc
Remove loggers for debugging
bjoaquinc Sep 29, 2025
f0bf09d
Merge branch 'marimo-team:main' into frontend-framework
bjoaquinc Sep 30, 2025
be02a6d
Revert changes in config.py, use stream_completion for adding additio…
bjoaquinc Sep 30, 2025
5743c94
Fix type issues and missing stream_completion position args
bjoaquinc Sep 30, 2025
7c131ff
Update using toJSONSchema from zod v4 and use ZodObject<z.ZodRawShape…
bjoaquinc Sep 30, 2025
fd89147
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 30, 2025
10567e2
Add missing positional argument to stream_completion
bjoaquinc Sep 30, 2025
f7315e0
Reduce code and simplify implementation
bjoaquinc Sep 30, 2025
0c90284
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 30, 2025
879298b
Add tests for frontend framework
bjoaquinc Sep 30, 2025
8f520dd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 30, 2025
d7836e8
hide tool in dev
mscolnick Oct 1, 2025
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
2 changes: 2 additions & 0 deletions frontend/src/components/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
type ChatId,
chatStateAtom,
} from "@/core/ai/state";
import { FRONTEND_TOOL_REGISTRY } from "@/core/ai/tools/registry";
import { aiAtom, aiEnabledAtom } from "@/core/config/config";
import { DEFAULT_AI_MODEL } from "@/core/config/config-schema";
import { FeatureFlagged } from "@/core/config/feature-flag";
Expand Down Expand Up @@ -585,6 +586,7 @@ const ChatPanelBody = () => {

return {
body: {
tools: FRONTEND_TOOL_REGISTRY.getToolSchemas(),
...options,
...completionBody,
},
Expand Down
33 changes: 24 additions & 9 deletions frontend/src/components/chat/chat-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type { components } from "@marimo-team/marimo-api";
import type { FileUIPart, ToolUIPart, UIMessage } from "ai";
import { FRONTEND_TOOL_REGISTRY } from "@/core/ai/tools/registry";
import type {
InvokeAiToolRequest,
InvokeAiToolResponse,
Expand Down Expand Up @@ -117,15 +118,29 @@ export async function handleToolCall({
};
}) {
try {
const response = await invokeAiTool({
toolName: toolCall.toolName,
arguments: toolCall.input,
});
addToolResult({
tool: toolCall.toolName,
toolCallId: toolCall.toolCallId,
output: response.result || response.error,
});
if (FRONTEND_TOOL_REGISTRY.has(toolCall.toolName)) {
// Invoke the frontend tool
const response = await FRONTEND_TOOL_REGISTRY.invoke(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we validate the mode here? or not needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think its needed since it's already hard typed. I'm more worried about the CopilotMode type in the frontend going out of sync with the backend.

toolCall.toolName,
toolCall.input,
);
addToolResult({
tool: toolCall.toolName,
toolCallId: toolCall.toolCallId,
output: response,
});
} else {
// Invoke the backend/mcp tool
const response = await invokeAiTool({
toolName: toolCall.toolName,
arguments: toolCall.input,
});
addToolResult({
tool: toolCall.toolName,
toolCallId: toolCall.toolCallId,
output: response.result || response.error,
});
}
} catch (error) {
Logger.error("Tool call failed:", error);
addToolResult({
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/core/ai/tools/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/* Copyright 2024 Marimo. All rights reserved. */

import type { z } from "zod";
import type { AnyZodObject } from "./registry";

/**
* Minimal base class for frontend tools.
*
* Structural typing ensures instances are compatible with the Tool<TIn, TOut>
* interface used by the registry, without importing it here.
*/
export abstract class BaseTool<
TIn extends AnyZodObject,
TOut extends AnyZodObject,
> {
public readonly name: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does not do much being abstract, and instead could just be an interface that others implement

export class MyTool implement AiTool {
   public readonly name = "run"
   public readonly schema = z.object()
....
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

public readonly description: string;
public readonly schema: TIn;
public readonly outputSchema: TOut;
public readonly mode: ("manual" | "ask")[];

/**
* Handler exposed to the registry. Calls the subclass implementation.
*/
public readonly handler: (
args: z.infer<TIn>,
) => z.infer<TOut> | Promise<z.infer<TOut>>;

constructor(options: {
name: string;
description: string;
schema: TIn;
mode: ("manual" | "ask")[];
outputSchema: TOut;
}) {
this.name = options.name;
this.description = options.description;
this.schema = options.schema;
this.mode = options.mode;
this.outputSchema = options.outputSchema;
this.handler = (args) => Promise.resolve(this.handle(args));
}

/** Implement tool logic in subclasses */
protected abstract handle(
args: z.infer<TIn>,
): z.infer<TOut> | Promise<z.infer<TOut>>;
}
132 changes: 132 additions & 0 deletions frontend/src/core/ai/tools/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/* Copyright 2024 Marimo. All rights reserved. */

import { type ZodObject, z } from "zod";
import type { BaseTool } from "./base";
import { testFrontendTool } from "./sample-tool";

export type AnyZodObject = ZodObject<z.ZodRawShape>;

interface StoredTool {
/** Generic type for to avoid type errors */
name: string;
description: string;
schema: AnyZodObject;
outputSchema: AnyZodObject;
mode: CopilotMode[];
handler: (args: unknown) => Promise<unknown>;
}

/** should be the same as marimo/_config/config.py > CopilotMode */
export type CopilotMode = "manual" | "ask";

export interface FrontendToolDefinition {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could grab this from import type { components } from "@marimo-team/marimo-api"; or at least extend it:

export interface FrontendToolDefinition extends ToolDefinition {
  source: "frontend";
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! I was looking for something like this thanks

Copy link
Contributor Author

@bjoaquinc bjoaquinc Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mscolnick can we also do this with CopilotMode?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep we can as well (but less worried it will get out of sync)

/** should be the same as marimo/_server/ai/tools/types.py > ToolDefinition */
name: string;
description: string;
parameters: Record<string, unknown>;
source: "frontend";
mode: CopilotMode[];
}

export class FrontendToolRegistry {
/** All registered tools */
private tools = new Map<string, StoredTool>();

registerAll<TIn extends AnyZodObject, TOut extends AnyZodObject>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could simplify this to:

constructor() {
  this.tools = new Map(tools.map(tool => [tool.id, tool])
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done! Nice we were able to get rid of registerAll() and register()

tools: BaseTool<TIn, TOut>[],
) {
tools.forEach((tool) => {
this.register(tool);
});
}

private register<TIn extends AnyZodObject, TOut extends AnyZodObject>(
tool: BaseTool<TIn, TOut>,
) {
// Make type generic to avoid type errors
// Let invoke() handle runtime type checking
const stored: StoredTool = {
name: tool.name,
description: tool.description,
schema: tool.schema,
outputSchema: tool.outputSchema,
mode: tool.mode,
handler: tool.handler as (args: unknown) => Promise<unknown>,
};
this.tools.set(tool.name, stored);
}

has(toolName: string) {
return this.tools.has(toolName);
}

private getTool(toolName: string): StoredTool {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: getToolOrThrow if it may throw

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

const tool = this.tools.get(toolName);
if (!tool) {
throw new Error(`Tool ${toolName} not found`);
}
return tool;
}

async invoke<TName extends string>(
toolName: TName,
rawArgs: unknown,
): Promise<unknown> {
const tool = this.getTool(toolName);
const handler = tool.handler;
const inputSchema = tool.schema;
const outputSchema = tool.outputSchema;

try {
// Parse input args
const inputResponse = await inputSchema.safeParseAsync(rawArgs);
if (inputResponse.error) {
const strError = z.prettifyError(inputResponse.error);
throw new Error(`Tool ${toolName} returned invalid input: ${strError}`);
}
const args = inputResponse.data;

// Call the handler
const rawOutput = await handler(args);

// Parse output
const response = await outputSchema.safeParseAsync(rawOutput);
if (response.error) {
const strError = z.prettifyError(response.error);
throw new Error(
`Tool ${toolName} returned invalid output: ${strError}`,
);
}
const output = response.data;
return output;
} catch (error) {
return {
status: "error",
code: "TOOL_ERROR",
message: error instanceof Error ? error.message : String(error),
suggestedFix: "Try again with valid arguments.",
meta: {
args: rawArgs,
},
};
}
}

getToolSchemas(): FrontendToolDefinition[] {
return [...this.tools.values()].map((tool) => ({
name: tool.name,
description: tool.description,
parameters: z.toJSONSchema(tool.schema),
source: "frontend",
mode: tool.mode,
}));
}
}

export const FRONTEND_TOOL_REGISTRY = new FrontendToolRegistry();

/* Register all the frontend tools */
FRONTEND_TOOL_REGISTRY.registerAll([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we put this in the constructor (instead of register), then we can confidently do @Memoize on the.getToolSchemas function

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

testFrontendTool,
// ADD MORE TOOLS HERE
]);
30 changes: 30 additions & 0 deletions frontend/src/core/ai/tools/sample-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* Copyright 2024 Marimo. All rights reserved. */

import { z } from "zod";
import { BaseTool } from "./base";

const schema = z.object({ name: z.string() });
const outputSchema = z.object({ message: z.string() });

/** A sample frontend tool that returns "hello world" */
export class TestFrontendTool extends BaseTool<
typeof schema,
typeof outputSchema
> {
constructor() {
super({
name: "test_frontend_tool",
description:
"A test frontend tool that returns hi with the name passed in",
schema,
outputSchema,
mode: ["ask"],
});
}

protected async handle({ name }: z.infer<typeof schema>) {
return { message: `Hello: ${name}` };
}
}

export const testFrontendTool = new TestFrontendTool();
4 changes: 3 additions & 1 deletion frontend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
/* Style */
"allowJs": true /* needed for storybook */,
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
/* Pin zod to root (v4); v3 exists via @modelcontextprotocol/sdk */
"zod": ["./node_modules/zod"]
},
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
Expand Down
2 changes: 2 additions & 0 deletions marimo/_cli/development/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def _generate_server_api_schema() -> dict[str, Any]:
from marimo._messaging.mimetypes import KnownMimeType
from marimo._plugins.core.web_component import JSONType
from marimo._runtime.packages.package_manager import PackageDescription
from marimo._server.ai.tools.types import ToolDefinition
from marimo._server.api.router import build_routes
from marimo._utils.dataclass_to_openapi import (
PythonTypeToOpenAPI,
Expand Down Expand Up @@ -136,6 +137,7 @@ def _generate_server_api_schema() -> dict[str, Any]:
ops.MessageOperation,
# ai
ChatMessage,
ToolDefinition,
]

# dataclass components used in requests/responses
Expand Down
Loading
Loading