feat: add hot-swappable executor support for collections and agents

- Add executor configuration to Collection and Agent models in Prisma schema
- Create ExecutorConfigPanel component for selecting default or custom executors
- Add executor resolution logic with cascade (Agent → Collection → System Default)
- Create /api/executors/verify endpoint to test custom executor connectivity
- Add executor documentation page at /docs/executors with API specification
- Create deployable Vercel executor template in templates/vercel-executor/
- Update MCP handlers and agent tool execution to use configurable executors
- Add executor types and schemas to @tpmjs/types package

Users can now deploy their own executor instances and configure collections
or agents to use custom executors instead of the TPMJS default executor.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-09 00:19:22 +10:00
parent 84d894e920
commit bc36d366bc
27 changed files with 1909 additions and 85 deletions

View file

@ -1,4 +1,4 @@
import { prisma } from '@tpmjs/db';
import { Prisma, prisma } from '@tpmjs/db';
import { UpdateAgentSchema } from '@tpmjs/types/agent';
import { headers } from 'next/headers';
import type { NextRequest } from 'next/server';
@ -178,23 +178,19 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
}
}
// Build update data, transforming executorConfig for Prisma (null -> Prisma.JsonNull)
const { executorConfig, ...restData } = parsed.data;
const updateData: Prisma.AgentUpdateInput = {
...restData,
...(executorConfig !== undefined && {
executorConfig: executorConfig === null ? Prisma.JsonNull : executorConfig,
}),
};
const agent = await prisma.agent.update({
where: { id },
data: parsed.data,
select: {
id: true,
uid: true,
name: true,
description: true,
provider: true,
modelId: true,
systemPrompt: true,
temperature: true,
maxToolCallsPerTurn: true,
maxMessagesInContext: true,
isPublic: true,
createdAt: true,
updatedAt: true,
data: updateData,
include: {
_count: {
select: {
tools: true,

View file

@ -1,4 +1,4 @@
import { prisma } from '@tpmjs/db';
import { Prisma, prisma } from '@tpmjs/db';
import { UpdateCollectionSchema } from '@tpmjs/types/collection';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
@ -243,7 +243,7 @@ export async function PATCH(
);
}
const { name, description, isPublic } = parseResult.data;
const { name, description, isPublic, executorType, executorConfig } = parseResult.data;
// If name is being changed, check for duplicates
if (name && name !== existingCollection.name) {
@ -270,13 +270,17 @@ export async function PATCH(
}
}
// Update collection
// Update collection (transform null to Prisma.JsonNull for JSON fields)
const collection = await prisma.collection.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(description !== undefined && { description }),
...(isPublic !== undefined && { isPublic }),
...(executorType !== undefined && { executorType }),
...(executorConfig !== undefined && {
executorConfig: executorConfig === null ? Prisma.JsonNull : executorConfig,
}),
},
include: {
_count: { select: { tools: true } },
@ -300,6 +304,8 @@ export async function PATCH(
description: collection.description,
isPublic: collection.isPublic,
toolCount: collection._count.tools,
executorType: collection.executorType,
executorConfig: collection.executorConfig,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
},

View file

@ -0,0 +1,116 @@
/**
* Executor Verification Endpoint
*
* POST: Verify that a custom executor URL is reachable and implements the API correctly
*/
import { VerifyExecutorRequestSchema } from '@tpmjs/types/executor';
import { type NextRequest, NextResponse } from 'next/server';
import { verifyExecutor } from '~/lib/executors';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 30;
/**
* POST /api/executors/verify
* Verify a custom executor URL
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const body = await request.json();
// Validate request
const parsed = VerifyExecutorRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request',
details: parsed.error.flatten(),
},
},
{ status: 400 }
);
}
const { url, apiKey } = parsed.data;
// Validate URL format and security
try {
const parsedUrl = new URL(url);
// Require HTTPS in production
if (process.env.NODE_ENV === 'production' && parsedUrl.protocol !== 'https:') {
return NextResponse.json(
{
success: false,
error: {
code: 'INSECURE_URL',
message: 'Custom executor URL must use HTTPS in production',
},
},
{ status: 400 }
);
}
// Block internal/private IPs in production
if (process.env.NODE_ENV === 'production') {
const hostname = parsedUrl.hostname;
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.16.') ||
hostname.endsWith('.local')
) {
return NextResponse.json(
{
success: false,
error: {
code: 'PRIVATE_URL',
message: 'Custom executor URL cannot point to private/internal addresses',
},
},
{ status: 400 }
);
}
}
} catch {
return NextResponse.json(
{
success: false,
error: {
code: 'INVALID_URL',
message: 'Invalid URL format',
},
},
{ status: 400 }
);
}
// Verify the executor
const result = await verifyExecutor(url, apiKey);
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
console.error('Failed to verify executor:', error);
return NextResponse.json(
{
success: false,
error: {
code: 'VERIFICATION_ERROR',
message: error instanceof Error ? error.message : 'Failed to verify executor',
},
},
{ status: 500 }
);
}
}

View file

@ -2,6 +2,7 @@
import type { AIProvider } from '@tpmjs/types/agent';
import { PROVIDER_MODELS, SUPPORTED_PROVIDERS } from '@tpmjs/types/agent';
import type { ExecutorConfig } from '@tpmjs/types/executor';
import { Button } from '@tpmjs/ui/Button/Button';
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Icon } from '@tpmjs/ui/Icon/Icon';
@ -19,6 +20,7 @@ import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
interface Agent {
@ -33,6 +35,8 @@ interface Agent {
maxToolCallsPerTurn: number;
maxMessagesInContext: number;
isPublic: boolean;
executorType: string | null;
executorConfig: { url: string; apiKey?: string } | null;
toolCount: number;
collectionCount: number;
createdAt: string;
@ -267,6 +271,7 @@ export default function AgentDetailPage(): React.ReactElement {
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [executorConfig, setExecutorConfig] = useState<ExecutorConfig | null>(null);
// Tools state
const [agentTools, setAgentTools] = useState<AgentTool[]>([]);
@ -316,6 +321,16 @@ export default function AgentDetailPage(): React.ReactElement {
maxMessagesInContext: data.data.maxMessagesInContext,
isPublic: data.data.isPublic,
});
// Initialize executor config state from agent data
if (data.data.executorType === 'custom_url' && data.data.executorConfig) {
setExecutorConfig({
type: 'custom_url',
url: data.data.executorConfig.url,
apiKey: data.data.executorConfig.apiKey,
});
} else {
setExecutorConfig(data.data.executorType ? { type: 'default' } : null);
}
} else {
if (response.status === 401) {
router.push('/sign-in');
@ -576,19 +591,39 @@ export default function AgentDetailPage(): React.ReactElement {
const handleSave = async () => {
setIsSaving(true);
// Build update payload including executor config
const updatePayload: Record<string, unknown> = {
...formData,
temperature: Number.parseFloat(formData.temperature.toString()),
maxToolCallsPerTurn: Number.parseInt(formData.maxToolCallsPerTurn.toString(), 10),
maxMessagesInContext: Number.parseInt(formData.maxMessagesInContext.toString(), 10),
description: formData.description || null,
systemPrompt: formData.systemPrompt || null,
isPublic: formData.isPublic,
};
// Add executor config
if (executorConfig) {
updatePayload.executorType = executorConfig.type;
if (executorConfig.type === 'custom_url') {
updatePayload.executorConfig = {
url: executorConfig.url,
apiKey: executorConfig.apiKey,
};
} else {
updatePayload.executorConfig = null;
}
} else {
updatePayload.executorType = null;
updatePayload.executorConfig = null;
}
try {
const response = await fetch(`/api/agents/${agentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
temperature: Number.parseFloat(formData.temperature.toString()),
maxToolCallsPerTurn: Number.parseInt(formData.maxToolCallsPerTurn.toString(), 10),
maxMessagesInContext: Number.parseInt(formData.maxMessagesInContext.toString(), 10),
description: formData.description || null,
systemPrompt: formData.systemPrompt || null,
isPublic: formData.isPublic,
}),
body: JSON.stringify(updatePayload),
});
const result = await response.json();
@ -863,6 +898,15 @@ export default function AgentDetailPage(): React.ReactElement {
/>
</div>
{/* Executor Configuration */}
<div className="pt-4 border-t border-border">
<ExecutorConfigPanel
value={executorConfig}
onChange={setExecutorConfig}
disabled={isSaving}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-4">
<Button
variant="outline"
@ -880,6 +924,16 @@ export default function AgentDetailPage(): React.ReactElement {
maxMessagesInContext: agent.maxMessagesInContext,
isPublic: agent.isPublic,
});
// Reset executor config to agent's current value
if (agent.executorType === 'custom_url' && agent.executorConfig) {
setExecutorConfig({
type: 'custom_url',
url: agent.executorConfig.url,
apiKey: agent.executorConfig.apiKey,
});
} else {
setExecutorConfig(agent.executorType ? { type: 'default' } : null);
}
}}
>
Cancel

View file

@ -1,11 +1,13 @@
'use client';
import type { ExecutorConfig } from '@tpmjs/types/executor';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
import { AddToolSearch } from '~/components/collections/AddToolSearch';
import { CollectionForm } from '~/components/collections/CollectionForm';
import { CollectionToolList } from '~/components/collections/CollectionToolList';
@ -165,12 +167,15 @@ interface Collection {
description: string | null;
isPublic: boolean;
toolCount: number;
executorType: string | null;
executorConfig: { url: string; apiKey?: string } | null;
createdAt: string;
updatedAt: string;
isOwner: boolean;
tools: CollectionTool[];
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Page component with multiple handlers
export default function CollectionDetailPage(): React.ReactElement {
const params = useParams();
const router = useRouter();
@ -183,7 +188,9 @@ export default function CollectionDetailPage(): React.ReactElement {
const [isUpdating, setIsUpdating] = useState(false);
const [removingToolId, setRemovingToolId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [executorConfig, setExecutorConfig] = useState<ExecutorConfig | null>(null);
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Fetch callback with error handling
const fetchCollection = useCallback(async () => {
try {
const response = await fetch(`/api/collections/${collectionId}`);
@ -191,6 +198,16 @@ export default function CollectionDetailPage(): React.ReactElement {
if (data.success) {
setCollection(data.data);
// Initialize executor config state from collection data
if (data.data.executorType === 'custom_url' && data.data.executorConfig) {
setExecutorConfig({
type: 'custom_url',
url: data.data.executorConfig.url,
apiKey: data.data.executorConfig.apiKey,
});
} else {
setExecutorConfig(data.data.executorType ? { type: 'default' } : null);
}
} else {
if (data.error?.code === 'UNAUTHORIZED') {
router.push('/sign-in');
@ -218,11 +235,28 @@ export default function CollectionDetailPage(): React.ReactElement {
if (!collection) return;
setIsUpdating(true);
// Build update payload including executor config
const updatePayload: Record<string, unknown> = { ...data };
if (executorConfig) {
updatePayload.executorType = executorConfig.type;
if (executorConfig.type === 'custom_url') {
updatePayload.executorConfig = {
url: executorConfig.url,
apiKey: executorConfig.apiKey,
};
} else {
updatePayload.executorConfig = null;
}
} else {
updatePayload.executorType = null;
updatePayload.executorConfig = null;
}
try {
const response = await fetch(`/api/collections/${collectionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
body: JSON.stringify(updatePayload),
});
const result = await response.json();
@ -235,6 +269,8 @@ export default function CollectionDetailPage(): React.ReactElement {
name: result.data.name,
description: result.data.description,
isPublic: result.data.isPublic,
executorType: result.data.executorType,
executorConfig: result.data.executorConfig,
updatedAt: result.data.updatedAt,
}
: null
@ -430,6 +466,15 @@ export default function CollectionDetailPage(): React.ReactElement {
isSubmitting={isUpdating}
submitLabel="Save Changes"
/>
{/* Executor Configuration */}
<div className="mt-6 pt-6 border-t border-border">
<ExecutorConfigPanel
value={executorConfig}
onChange={setExecutorConfig}
disabled={isUpdating}
/>
</div>
</div>
)}

View file

@ -0,0 +1,304 @@
import { Button } from '@tpmjs/ui/Button/Button';
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import type { Metadata } from 'next';
import Link from 'next/link';
import { AppFooter } from '~/components/AppFooter';
import { AppHeader } from '~/components/AppHeader';
export const metadata: Metadata = {
title: 'Custom Executors - TPMJS',
description:
'Learn how to deploy and configure custom executors for running TPMJS tools on your own infrastructure.',
};
const executeToolExample = `// POST /execute-tool
{
"packageName": "@tpmjs/hello",
"name": "helloWorld",
"version": "latest",
"params": { "name": "World" },
"env": { "MY_API_KEY": "..." }
}`;
const executeToolResponse = `// Response
{
"success": true,
"output": "Hello, World!",
"executionTimeMs": 123
}`;
const healthExample = `// GET /health
{
"status": "ok",
"version": "1.0.0"
}`;
export default function ExecutorsDocsPage(): React.ReactElement {
return (
<div className="min-h-screen bg-background flex flex-col">
<AppHeader />
<main className="flex-1">
<div className="max-w-4xl mx-auto px-4 py-12">
{/* Header */}
<div className="mb-12">
<h1 className="text-3xl font-bold text-foreground mb-4">Custom Executors</h1>
<p className="text-lg text-foreground-secondary">
Deploy your own executor to run TPMJS tools on your own infrastructure.
</p>
</div>
{/* Overview Section */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">What is an Executor?</h2>
<p className="text-foreground-secondary mb-4">
An executor is a service that runs TPMJS tools. When you use a collection or agent,
TPMJS sends tool execution requests to an executor, which dynamically loads and runs
the tool code.
</p>
<p className="text-foreground-secondary mb-6">
By default, TPMJS uses a shared executor. You can deploy your own for:
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Icon icon="folder" className="w-5 h-5 text-primary" />
<h3 className="font-medium text-foreground">Full Control</h3>
</div>
<p className="text-sm text-foreground-secondary">
Run tools on your own infrastructure with complete control over the execution
environment.
</p>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Icon icon="globe" className="w-5 h-5 text-primary" />
<h3 className="font-medium text-foreground">Privacy</h3>
</div>
<p className="text-sm text-foreground-secondary">
Keep tool execution data on your own servers. No data leaves your infrastructure.
</p>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Icon icon="clock" className="w-5 h-5 text-primary" />
<h3 className="font-medium text-foreground">Performance</h3>
</div>
<p className="text-sm text-foreground-secondary">
Deploy in regions closest to your users for lower latency tool execution.
</p>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Icon icon="edit" className="w-5 h-5 text-primary" />
<h3 className="font-medium text-foreground">Custom Environment</h3>
</div>
<p className="text-sm text-foreground-secondary">
Inject your own environment variables, secrets, and configuration into tool
execution.
</p>
</div>
</div>
</section>
{/* Deploy Section */}
<section id="deploy" className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">
Deploy Your Own Executor
</h2>
<p className="text-foreground-secondary mb-6">
The fastest way to get started is to deploy our template to Vercel with one click:
</p>
<div className="mb-6">
<a
href="https://vercel.com/new/clone?repository-url=https://github.com/tpmjs/tpmjs/tree/main/templates/vercel-executor&project-name=tpmjs-executor&repository-name=tpmjs-executor"
target="_blank"
rel="noopener noreferrer"
>
<Button size="lg">
<Icon icon="externalLink" className="w-4 h-4 mr-2" />
Deploy to Vercel
</Button>
</a>
</div>
<p className="text-sm text-foreground-tertiary">
After deployment, you&apos;ll get a URL like{' '}
<code className="px-1.5 py-0.5 bg-surface rounded text-foreground-secondary">
https://tpmjs-executor.vercel.app
</code>
</p>
</section>
{/* Configuration Section */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Configuration</h2>
<p className="text-foreground-secondary mb-4">
Once you have your executor deployed, configure your collections or agents to use it:
</p>
<ol className="list-decimal list-inside text-foreground-secondary space-y-3 mb-6">
<li>Go to your collection or agent settings</li>
<li>
In the &quot;Executor Configuration&quot; section, select &quot;Custom
Executor&quot;
</li>
<li>
Enter your executor URL (e.g.,{' '}
<code className="px-1.5 py-0.5 bg-surface rounded">
https://tpmjs-executor.vercel.app
</code>
)
</li>
<li>Optionally add an API key if your executor requires authentication</li>
<li>Click &quot;Verify Connection&quot; to test the configuration</li>
</ol>
<div className="p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg">
<p className="text-sm text-amber-600 dark:text-amber-400">
<strong>Security tip:</strong> Set the{' '}
<code className="px-1 bg-amber-500/20 rounded">EXECUTOR_API_KEY</code> environment
variable in your Vercel project to require authentication for all requests.
</p>
</div>
</section>
{/* API Specification */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">
Executor API Specification
</h2>
<p className="text-foreground-secondary mb-6">All executors must implement this API:</p>
{/* POST /execute-tool */}
<div className="mb-8">
<h3 className="text-lg font-medium text-foreground mb-2">
<code className="px-2 py-1 bg-primary/10 text-primary rounded">POST</code>{' '}
/execute-tool
</h3>
<p className="text-foreground-secondary mb-4">
Execute a TPMJS tool with the provided parameters.
</p>
<div className="space-y-4">
<div>
<p className="text-sm font-medium text-foreground mb-2">Request Body:</p>
<CodeBlock language="json" code={executeToolExample} />
</div>
<div>
<p className="text-sm font-medium text-foreground mb-2">Response:</p>
<CodeBlock language="json" code={executeToolResponse} />
</div>
</div>
</div>
{/* GET /health */}
<div className="mb-8">
<h3 className="text-lg font-medium text-foreground mb-2">
<code className="px-2 py-1 bg-green-500/10 text-green-500 rounded">GET</code>{' '}
/health
</h3>
<p className="text-foreground-secondary mb-4">
Check executor health status. Used by TPMJS to verify the executor is reachable.
</p>
<div>
<p className="text-sm font-medium text-foreground mb-2">Response:</p>
<CodeBlock language="json" code={healthExample} />
</div>
</div>
</section>
{/* Cascade Section */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Executor Cascade</h2>
<p className="text-foreground-secondary mb-4">
Executor configuration follows a cascade resolution order:
</p>
<div className="flex items-center gap-2 text-foreground-secondary mb-4">
<span className="px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
Agent Config
</span>
<Icon icon="chevronRight" className="w-4 h-4" />
<span className="px-3 py-1 bg-surface border border-border rounded-full text-sm">
Collection Config
</span>
<Icon icon="chevronRight" className="w-4 h-4" />
<span className="px-3 py-1 bg-surface border border-border rounded-full text-sm">
System Default
</span>
</div>
<ul className="text-foreground-secondary text-sm space-y-2">
<li> If an agent has an executor configured, all tools in that agent use it</li>
<li>
If the agent has no executor but a collection does, tools from that collection use
the collection&apos;s executor
</li>
<li> If neither has an executor configured, the TPMJS default executor is used</li>
</ul>
</section>
{/* FAQ Section */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">FAQ</h2>
<div className="space-y-6">
<div>
<h3 className="font-medium text-foreground mb-2">Can I use any cloud provider?</h3>
<p className="text-foreground-secondary text-sm">
Yes! While we provide a Vercel template, you can deploy an executor anywhere that
can run Node.js and expose an HTTP endpoint. The executor just needs to implement
the API specification above.
</p>
</div>
<div>
<h3 className="font-medium text-foreground mb-2">What about timeouts?</h3>
<p className="text-foreground-secondary text-sm">
The default timeout for tool execution is 30 seconds. On Vercel&apos;s free tier,
you get up to 10 seconds per request. For longer-running tools, consider deploying
to a platform with higher timeout limits.
</p>
</div>
<div>
<h3 className="font-medium text-foreground mb-2">How do tools get loaded?</h3>
<p className="text-foreground-secondary text-sm">
Tools are dynamically imported from{' '}
<Link href="https://esm.sh" className="text-primary hover:underline">
esm.sh
</Link>
, a CDN for npm packages. The executor fetches the package, finds the tool export,
and calls its <code className="px-1 bg-surface rounded">execute()</code> function.
</p>
</div>
</div>
</section>
{/* Support */}
<section className="p-6 bg-surface border border-border rounded-lg">
<h2 className="text-lg font-semibold text-foreground mb-2">Need Help?</h2>
<p className="text-foreground-secondary text-sm mb-4">
If you run into issues deploying or configuring your executor, we&apos;re here to
help.
</p>
<div className="flex gap-3">
<a
href="https://github.com/tpmjs/tpmjs/issues"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="secondary" size="sm">
<Icon icon="github" className="w-4 h-4 mr-2" />
Open an Issue
</Button>
</a>
<a href="https://discord.gg/tpmjs" target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="sm">
<Icon icon="discord" className="w-4 h-4 mr-2" />
Join Discord
</Button>
</a>
</div>
</section>
</div>
</main>
<AppFooter />
</div>
);
}

View file

@ -0,0 +1,315 @@
'use client';
import type { ExecutorConfig } from '@tpmjs/types/executor';
import { Button } from '@tpmjs/ui/Button/Button';
import { FormField } from '@tpmjs/ui/FormField/FormField';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Input } from '@tpmjs/ui/Input/Input';
import Link from 'next/link';
import { useState } from 'react';
interface ExecutorConfigPanelProps {
value: ExecutorConfig | null;
onChange: (config: ExecutorConfig | null) => void;
disabled?: boolean;
}
interface VerificationResult {
valid: boolean;
healthCheck?: {
healthy: boolean;
response?: { status: string; version?: string };
};
testExecution?: {
success: boolean;
executionTimeMs: number;
};
errors: string[];
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Component has multiple states for executor configuration
export function ExecutorConfigPanel({
value,
onChange,
disabled = false,
}: ExecutorConfigPanelProps): React.ReactElement {
const [executorType, setExecutorType] = useState<'default' | 'custom_url'>(
value?.type === 'custom_url' ? 'custom_url' : 'default'
);
const [customUrl, setCustomUrl] = useState(value?.type === 'custom_url' ? value.url : '');
const [apiKey, setApiKey] = useState(value?.type === 'custom_url' ? (value.apiKey ?? '') : '');
const [isVerifying, setIsVerifying] = useState(false);
const [verificationResult, setVerificationResult] = useState<VerificationResult | null>(null);
const [urlError, setUrlError] = useState<string | null>(null);
const handleTypeChange = (type: 'default' | 'custom_url') => {
setExecutorType(type);
setVerificationResult(null);
setUrlError(null);
if (type === 'default') {
onChange({ type: 'default' });
} else {
// Don't update parent until URL is provided
if (customUrl) {
onChange({
type: 'custom_url',
url: customUrl,
apiKey: apiKey || undefined,
});
}
}
};
const handleUrlChange = (url: string) => {
setCustomUrl(url);
setVerificationResult(null);
setUrlError(null);
if (url) {
onChange({
type: 'custom_url',
url,
apiKey: apiKey || undefined,
});
}
};
const handleApiKeyChange = (key: string) => {
setApiKey(key);
if (customUrl) {
onChange({
type: 'custom_url',
url: customUrl,
apiKey: key || undefined,
});
}
};
const handleVerify = async () => {
if (!customUrl) {
setUrlError('URL is required');
return;
}
// Validate URL format
try {
new URL(customUrl);
} catch {
setUrlError('Invalid URL format');
return;
}
setIsVerifying(true);
setVerificationResult(null);
setUrlError(null);
try {
const response = await fetch('/api/executors/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: customUrl, apiKey: apiKey || undefined }),
});
const data = await response.json();
if (data.success) {
setVerificationResult(data.data);
} else {
setUrlError(data.error?.message || 'Verification failed');
}
} catch (error) {
setUrlError(error instanceof Error ? error.message : 'Failed to verify executor');
} finally {
setIsVerifying(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<Icon icon="link" className="w-4 h-4 text-foreground-secondary" />
<h3 className="text-sm font-medium text-foreground">Executor Configuration</h3>
</div>
<p className="text-xs text-foreground-tertiary">
Choose where tools in this collection/agent will be executed.{' '}
<Link href="/docs/executors" className="text-primary hover:underline">
Learn more
</Link>
</p>
{/* Executor Type Selection */}
<div className="flex gap-3">
<button
type="button"
onClick={() => handleTypeChange('default')}
disabled={disabled}
className={`flex-1 p-3 rounded-lg border-2 text-left transition-colors ${
executorType === 'default'
? 'border-primary bg-primary/5'
: 'border-border hover:border-foreground-secondary'
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<div className="flex items-center gap-2">
<div
className={`w-4 h-4 rounded-full border-2 ${
executorType === 'default'
? 'border-primary bg-primary'
: 'border-foreground-tertiary'
}`}
>
{executorType === 'default' && (
<div className="w-full h-full flex items-center justify-center">
<div className="w-1.5 h-1.5 bg-white rounded-full" />
</div>
)}
</div>
<span className="font-medium text-sm text-foreground">TPMJS Default</span>
</div>
<p className="text-xs text-foreground-tertiary mt-1 ml-6">
Free, managed executor hosted by TPMJS
</p>
</button>
<button
type="button"
onClick={() => handleTypeChange('custom_url')}
disabled={disabled}
className={`flex-1 p-3 rounded-lg border-2 text-left transition-colors ${
executorType === 'custom_url'
? 'border-primary bg-primary/5'
: 'border-border hover:border-foreground-secondary'
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<div className="flex items-center gap-2">
<div
className={`w-4 h-4 rounded-full border-2 ${
executorType === 'custom_url'
? 'border-primary bg-primary'
: 'border-foreground-tertiary'
}`}
>
{executorType === 'custom_url' && (
<div className="w-full h-full flex items-center justify-center">
<div className="w-1.5 h-1.5 bg-white rounded-full" />
</div>
)}
</div>
<span className="font-medium text-sm text-foreground">Custom Executor</span>
</div>
<p className="text-xs text-foreground-tertiary mt-1 ml-6">
Self-hosted executor on your infrastructure
</p>
</button>
</div>
{/* Custom URL Configuration */}
{executorType === 'custom_url' && (
<div className="space-y-3 pt-2 border-t border-border">
<FormField
label="Executor URL"
htmlFor="executor-url"
required
error={urlError ?? undefined}
state={urlError ? 'error' : 'default'}
helperText="The base URL of your executor (e.g., https://my-executor.vercel.app)"
>
<Input
id="executor-url"
type="url"
value={customUrl}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder="https://my-executor.vercel.app"
state={urlError ? 'error' : 'default'}
disabled={disabled}
/>
</FormField>
<FormField
label="API Key (Optional)"
htmlFor="executor-api-key"
helperText="If your executor requires authentication"
>
<Input
id="executor-api-key"
type="password"
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder="sk-..."
disabled={disabled}
/>
</FormField>
<div className="flex items-center gap-3">
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleVerify}
loading={isVerifying}
disabled={disabled || !customUrl}
>
<Icon icon="check" className="w-4 h-4 mr-1" />
Verify Connection
</Button>
<Link href="/docs/executors#deploy" className="text-xs text-primary hover:underline">
Deploy your own executor
</Link>
</div>
{/* Verification Result */}
{verificationResult && (
<div
className={`p-3 rounded-lg text-sm ${
verificationResult.valid
? 'bg-green-500/10 border border-green-500/30'
: 'bg-red-500/10 border border-red-500/30'
}`}
>
<div className="flex items-center gap-2">
<Icon
icon={verificationResult.valid ? 'check' : 'x'}
className={`w-4 h-4 ${
verificationResult.valid ? 'text-green-500' : 'text-red-500'
}`}
/>
<span
className={`font-medium ${
verificationResult.valid ? 'text-green-500' : 'text-red-500'
}`}
>
{verificationResult.valid ? 'Executor verified' : 'Verification failed'}
</span>
</div>
{verificationResult.healthCheck && (
<p className="text-xs text-foreground-secondary mt-2 ml-6">
Health: {verificationResult.healthCheck.response?.status ?? 'unknown'}
{verificationResult.healthCheck.response?.version &&
` (v${verificationResult.healthCheck.response.version})`}
</p>
)}
{verificationResult.testExecution && (
<p className="text-xs text-foreground-secondary ml-6">
Test execution: {verificationResult.testExecution.executionTimeMs}ms
</p>
)}
{verificationResult.errors.length > 0 && (
<ul className="text-xs text-red-400 mt-2 ml-6 list-disc list-inside">
{verificationResult.errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -6,7 +6,9 @@ import type { Agent, AgentCollection, AgentTool, Collection, Package, Tool } fro
import { prisma } from '@tpmjs/db';
import { createToolDefinition } from '../ai-agent/tool-executor-agent';
import { parseExecutorConfig, resolveExecutorConfig } from '../executors';
// Agent type includes executor config fields from Prisma schema
type AgentWithRelations = Agent & {
collections: (AgentCollection & {
collection: Collection & {
@ -22,6 +24,7 @@ type AgentWithRelations = Agent & {
/**
* Fetch a full agent with all tool relations
* Includes executor config for cascade resolution
*/
export async function fetchAgentWithTools(agentId: string): Promise<AgentWithRelations | null> {
return prisma.agent.findUnique({
@ -58,6 +61,7 @@ export async function fetchAgentWithTools(agentId: string): Promise<AgentWithRel
/**
* Fetch an agent by UID with all tool relations
* Includes executor config for cascade resolution
*/
export async function fetchAgentByUidWithTools(uid: string): Promise<AgentWithRelations | null> {
return prisma.agent.findUnique({
@ -95,6 +99,7 @@ export async function fetchAgentByUidWithTools(uid: string): Promise<AgentWithRe
/**
* Fetch an agent by ID or UID with all tool relations
* Accepts either the cuid or the user-friendly uid
* Includes executor config for cascade resolution
*/
export async function fetchAgentByIdOrUidWithTools(
idOrUid: string
@ -136,6 +141,7 @@ export async function fetchAgentByIdOrUidWithTools(
/**
* Fetch an agent by username and uid with all tool relations
* Uses the username/uid pretty URL format
* Includes executor config for cascade resolution
*/
export async function fetchAgentByUsernameAndUidWithTools(
username: string,
@ -189,6 +195,11 @@ function sanitizeToolName(name: string): string {
/**
* Build all tools from an agent's collections and individual tools
* Returns a map of tool name -> AI SDK tool definition
*
* Executor config cascade: Agent Collection System Default
* - If agent has an executor config, it's used for all tools
* - If agent has no config but collection has one, collection's config is used for tools from that collection
* - If neither has config, system default is used
*/
export function buildAgentTools(
agent: AgentWithRelations
@ -196,9 +207,23 @@ export function buildAgentTools(
const tools: Record<string, ReturnType<typeof createToolDefinition>> = {};
const seenTools = new Set<string>();
// Parse agent-level executor config
const agentExecutorConfig = parseExecutorConfig(agent.executorType, agent.executorConfig);
// Add tools from collections first
for (const agentCollection of agent.collections) {
for (const collectionTool of agentCollection.collection.tools) {
const collection = agentCollection.collection;
// Parse collection-level executor config
const collectionExecutorConfig = parseExecutorConfig(
collection.executorType,
collection.executorConfig
);
// Resolve executor config: Agent → Collection → Default
const resolvedConfig = resolveExecutorConfig(agentExecutorConfig, collectionExecutorConfig);
for (const collectionTool of collection.tools) {
const tool = collectionTool.tool;
const toolKey = `${tool.package.npmPackageName}::${tool.name}`;
@ -207,11 +232,14 @@ export function buildAgentTools(
seenTools.add(toolKey);
const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`);
tools[toolName] = createToolDefinition(tool);
tools[toolName] = createToolDefinition(tool, resolvedConfig);
}
}
// Add individual tools (may override collection tools)
// Individual tools use agent config or system default (no collection context)
const individualToolConfig = agentExecutorConfig ?? { type: 'default' as const };
for (const agentTool of agent.tools) {
const tool = agentTool.tool;
const toolKey = `${tool.package.npmPackageName}::${tool.name}`;
@ -221,7 +249,7 @@ export function buildAgentTools(
seenTools.add(toolKey);
const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`);
tools[toolName] = createToolDefinition(tool);
tools[toolName] = createToolDefinition(tool, individualToolConfig);
}
return tools;

View file

@ -5,10 +5,12 @@
import { openai } from '@ai-sdk/openai';
import type { Package, Tool } from '@tpmjs/db';
import { executePackage } from '@tpmjs/package-executor';
import type { ExecutorConfig } from '@tpmjs/types/executor';
import { type ModelMessage, generateText } from 'ai';
import { z } from 'zod';
import { executeWithExecutor } from '../executors';
/**
* Parameter from TPMJS metadata
*/
@ -91,8 +93,14 @@ export function tpmjsParamsToZodSchema(parameters: TPMJSParameter[]): z.ZodObjec
/**
* Create AI SDK v6 tool definition from TPMJS Tool
* Requires Tool with Package relation
*
* @param tool - The tool with its package relation
* @param executorConfig - Optional executor config for custom executors
*/
export function createToolDefinition(tool: Tool & { package: Package }) {
export function createToolDefinition(
tool: Tool & { package: Package },
executorConfig?: ExecutorConfig | null
) {
const parameters = Array.isArray(tool.parameters)
? (tool.parameters as unknown as TPMJSParameter[])
: [];
@ -118,14 +126,13 @@ export function createToolDefinition(tool: Tool & { package: Package }) {
execute: async (params: Record<string, unknown>) => {
console.log('[Tool execute] Running:', sanitizedName, params);
// Execute the actual npm package in a sandbox
// Execute the actual npm package using resolved executor
// Use the actual export name from the Tool record
const result = await executePackage(
tool.package.npmPackageName,
tool.name, // Use actual export name (e.g., "helloWorldTool", "default")
const result = await executeWithExecutor(executorConfig ?? null, {
packageName: tool.package.npmPackageName,
name: tool.name, // Use actual export name (e.g., "helloWorldTool", "default")
params,
{ timeout: 5000 }
);
});
if (!result.success) {
throw new Error(result.error || 'Package execution failed');

View file

@ -0,0 +1,335 @@
/**
* Executor Resolution Logic
*
* Handles the cascade resolution for hot-swappable executors:
* Agent Config Collection Config System Default
*/
import { executePackage } from '@tpmjs/package-executor';
import type {
ExecuteToolRequest,
ExecuteToolResponse,
ExecutorConfig,
ExecutorHealthResponse,
} from '@tpmjs/types/executor';
const DEFAULT_TIMEOUT = 30000; // 30 seconds for custom executors
/**
* Resolve executor configuration using cascade:
* Agent Config Collection Config System Default
*
* @param agentConfig - Executor config from Agent (highest priority)
* @param collectionConfig - Executor config from Collection (medium priority)
* @returns Resolved executor configuration
*/
export function resolveExecutorConfig(
agentConfig: ExecutorConfig | null | undefined,
collectionConfig: ExecutorConfig | null | undefined
): ExecutorConfig {
// Agent config takes precedence
if (agentConfig && agentConfig.type !== 'default') {
return agentConfig;
}
// Fall back to collection config
if (collectionConfig && collectionConfig.type !== 'default') {
return collectionConfig;
}
// System default
return { type: 'default' };
}
/**
* Parse executor config from database JSON
* Handles null/undefined and validates the shape
*/
export function parseExecutorConfig(
executorType: string | null | undefined,
executorConfig: unknown
): ExecutorConfig | null {
if (!executorType) {
return null;
}
if (executorType === 'default') {
return { type: 'default' };
}
if (executorType === 'custom_url' && executorConfig && typeof executorConfig === 'object') {
const config = executorConfig as { url?: string; apiKey?: string };
if (config.url && typeof config.url === 'string') {
return {
type: 'custom_url',
url: config.url,
apiKey: typeof config.apiKey === 'string' ? config.apiKey : undefined,
};
}
}
return null;
}
/**
* Execute a tool using a custom URL executor
*/
async function executeWithCustomUrl(
url: string,
apiKey: string | undefined,
request: ExecuteToolRequest,
timeout: number = DEFAULT_TIMEOUT
): Promise<ExecuteToolResponse> {
const startTime = Date.now();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(`${url}/execute-tool`, {
method: 'POST',
headers,
body: JSON.stringify(request),
signal: controller.signal,
});
clearTimeout(timeoutId);
const executionTimeMs = Date.now() - startTime;
if (!response.ok) {
const errorData = (await response.json().catch(() => ({ error: 'Unknown error' }))) as {
error?: string;
};
return {
success: false,
error: errorData.error || `Executor error: ${response.status}`,
executionTimeMs,
};
}
const result = (await response.json()) as ExecuteToolResponse;
return {
success: result.success,
output: result.output,
error: result.error,
executionTimeMs: result.executionTimeMs || executionTimeMs,
};
} catch (error) {
const executionTimeMs = Date.now() - startTime;
if (error instanceof Error && error.name === 'AbortError') {
return {
success: false,
error: 'Execution timeout',
executionTimeMs,
};
}
return {
success: false,
error: error instanceof Error ? error.message : String(error),
executionTimeMs,
};
}
}
/**
* Execute a tool using the resolved executor configuration
*
* @param config - Resolved executor config (or null for default)
* @param request - Tool execution request
* @returns Execution result
*/
export async function executeWithExecutor(
config: ExecutorConfig | null,
request: ExecuteToolRequest
): Promise<ExecuteToolResponse> {
const resolvedConfig = config ?? { type: 'default' };
// Use custom URL executor
if (resolvedConfig.type === 'custom_url' && resolvedConfig.url) {
return executeWithCustomUrl(resolvedConfig.url, resolvedConfig.apiKey, request);
}
// Default: use existing package-executor (which uses SANDBOX_EXECUTOR_URL)
const result = await executePackage(request.packageName, request.name, request.params);
return {
success: result.success,
output: result.output,
error: result.error,
executionTimeMs: result.executionTimeMs,
};
}
/**
* Check the health of a custom executor URL
*
* @param url - Executor URL to check
* @param apiKey - Optional API key for authentication
* @returns Health check response or error
*/
export async function checkExecutorHealth(
url: string,
apiKey?: string
): Promise<{ healthy: boolean; response?: ExecutorHealthResponse; error?: string }> {
const headers: Record<string, string> = {};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout for health checks
const response = await fetch(`${url}/health`, {
method: 'GET',
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return {
healthy: false,
error: `Health check failed: ${response.status}`,
};
}
const data = (await response.json()) as ExecutorHealthResponse;
return {
healthy: data.status === 'ok' || data.status === 'degraded',
response: data,
};
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return {
healthy: false,
error: 'Health check timeout',
};
}
return {
healthy: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Test a custom executor by running a simple tool execution
*
* @param url - Executor URL to test
* @param apiKey - Optional API key for authentication
* @returns Test execution result
*/
export async function testExecutor(
url: string,
apiKey?: string
): Promise<{ success: boolean; executionTimeMs: number; error?: string }> {
const testRequest: ExecuteToolRequest = {
packageName: '@anthropic-ai/tpmjs-hello',
name: 'helloWorld',
params: { name: 'test' },
};
const result = await executeWithCustomUrl(url, apiKey, testRequest, 15000); // 15 second timeout for test
return {
success: result.success,
executionTimeMs: result.executionTimeMs,
error: result.error,
};
}
/**
* Verify a custom executor URL is valid and operational
*
* @param url - Executor URL to verify
* @param apiKey - Optional API key for authentication
* @returns Verification result
*/
export async function verifyExecutor(
url: string,
apiKey?: string
): Promise<{
valid: boolean;
healthCheck?: { healthy: boolean; response?: ExecutorHealthResponse };
testExecution?: { success: boolean; executionTimeMs: number };
errors: string[];
}> {
const errors: string[] = [];
// Validate URL format
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && process.env.NODE_ENV === 'production') {
errors.push('Custom executor URL must use HTTPS in production');
}
} catch {
errors.push('Invalid URL format');
return { valid: false, errors };
}
// Check health endpoint
const healthResult = await checkExecutorHealth(url, apiKey);
if (!healthResult.healthy) {
errors.push(healthResult.error || 'Health check failed');
}
// Test execution (only if health check passed)
let testResult: { success: boolean; executionTimeMs: number } | undefined;
if (healthResult.healthy) {
const execResult = await testExecutor(url, apiKey);
testResult = {
success: execResult.success,
executionTimeMs: execResult.executionTimeMs,
};
if (!execResult.success) {
errors.push(execResult.error || 'Test execution failed');
}
}
return {
valid: errors.length === 0,
healthCheck: {
healthy: healthResult.healthy,
response: healthResult.response,
},
testExecution: testResult,
errors,
};
}
/**
* Get executor description for display
*/
export function getExecutorDescription(config: ExecutorConfig | null): string {
if (!config || config.type === 'default') {
return 'TPMJS Default Executor';
}
if (config.type === 'custom_url') {
try {
const url = new URL(config.url);
return `Custom: ${url.hostname}`;
} catch {
return 'Custom Executor';
}
}
return 'Unknown Executor';
}

View file

@ -1,44 +1,8 @@
import { prisma } from '@tpmjs/db';
import { executeWithExecutor, parseExecutorConfig } from '../executors';
import { convertToMcpTool, parseToolName } from './tool-converter';
const SANDBOX_URL = 'https://executor.tpmjs.com';
interface ExecutionResult {
success: boolean;
output?: unknown;
error?: string;
executionTimeMs?: number;
}
async function executePackageDirect(
packageName: string,
toolName: string,
params: Record<string, unknown>
): Promise<ExecutionResult> {
try {
const response = await fetch(`${SANDBOX_URL}/execute-tool`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
packageName,
name: toolName,
version: 'latest',
params,
}),
});
if (!response.ok) {
const text = await response.text();
return { success: false, error: `Sandbox error ${response.status}: ${text}` };
}
return await response.json();
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
type JsonRpcId = string | number | null;
interface JsonRpcResponse {
@ -114,10 +78,12 @@ export async function handleToolsCall(
};
}
// Verify tool exists in collection
// Verify tool exists in collection and get executor config
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
include: {
select: {
executorType: true,
executorConfig: true,
tools: {
include: { tool: { include: { package: true } } },
},
@ -137,12 +103,15 @@ export async function handleToolsCall(
};
}
// Execute via sandbox (direct call with hardcoded URL)
const result = await executePackageDirect(
parsed.packageName,
parsed.toolName,
params.arguments ?? {}
);
// Resolve executor configuration (collection config only for MCP - no agent context)
const executorConfig = parseExecutorConfig(collection?.executorType, collection?.executorConfig);
// Execute via resolved executor
const result = await executeWithExecutor(executorConfig, {
packageName: parsed.packageName,
name: parsed.toolName,
params: params.arguments ?? {},
});
if (!result.success) {
return {