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:
parent
84d894e920
commit
bc36d366bc
27 changed files with 1909 additions and 85 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
116
apps/web/src/app/api/executors/verify/route.ts
Normal file
116
apps/web/src/app/api/executors/verify/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
304
apps/web/src/app/docs/executors/page.tsx
Normal file
304
apps/web/src/app/docs/executors/page.tsx
Normal 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'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 "Executor Configuration" section, select "Custom
|
||||
Executor"
|
||||
</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 "Verify Connection" 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'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'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'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>
|
||||
);
|
||||
}
|
||||
315
apps/web/src/components/ExecutorConfigPanel.tsx
Normal file
315
apps/web/src/components/ExecutorConfigPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
335
apps/web/src/lib/executors/index.ts
Normal file
335
apps/web/src/lib/executors/index.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -422,6 +422,10 @@ model Collection {
|
|||
isPublic Boolean @default(false) @map("is_public")
|
||||
likeCount Int @default(0) @map("like_count")
|
||||
|
||||
// Executor configuration (optional - uses system default if not set)
|
||||
executorType String? @map("executor_type") @db.VarChar(50)
|
||||
executorConfig Json? @map("executor_config") @db.JsonB
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
|
@ -513,6 +517,11 @@ model Agent {
|
|||
isPublic Boolean @default(true) @map("is_public")
|
||||
likeCount Int @default(0) @map("like_count")
|
||||
|
||||
// Executor configuration (optional - uses system default if not set)
|
||||
// Agent executor overrides collection executor overrides system default
|
||||
executorType String? @map("executor_type") @db.VarChar(50)
|
||||
executorConfig Json? @map("executor_config") @db.JsonB
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@
|
|||
"./user": {
|
||||
"types": "./dist/user.d.ts",
|
||||
"default": "./dist/user.js"
|
||||
},
|
||||
"./executor": {
|
||||
"types": "./dist/executor.d.ts",
|
||||
"default": "./dist/executor.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { ExecutorTypeSchema } from './executor';
|
||||
|
||||
// Regex for valid agent UID: lowercase alphanumeric and hyphens
|
||||
const UID_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
||||
|
||||
// Executor config for updates (simplified schema that maps to database JSON)
|
||||
const ExecutorConfigUpdateSchema = z
|
||||
.object({
|
||||
url: z.string().url(),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
// ============================================================================
|
||||
// Enums
|
||||
// ============================================================================
|
||||
|
|
@ -49,6 +60,9 @@ export const UpdateAgentSchema = z.object({
|
|||
maxToolCallsPerTurn: z.number().int().min(1).max(100).optional(),
|
||||
maxMessagesInContext: z.number().int().min(1).max(100).optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
// Executor configuration
|
||||
executorType: ExecutorTypeSchema.nullable().optional(),
|
||||
executorConfig: ExecutorConfigUpdateSchema,
|
||||
});
|
||||
|
||||
export const AddCollectionToAgentSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { ExecutorTypeSchema } from './executor';
|
||||
|
||||
// Regex for valid collection names: letters, numbers, spaces, hyphens, underscores
|
||||
const NAME_REGEX = /^[a-zA-Z0-9\s\-_]+$/;
|
||||
|
||||
// Executor config for updates (simplified schema that maps to database JSON)
|
||||
const ExecutorConfigUpdateSchema = z
|
||||
.object({
|
||||
url: z.string().url(),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
// ============================================================================
|
||||
// Collection Schemas
|
||||
// ============================================================================
|
||||
|
|
@ -30,6 +41,9 @@ export const UpdateCollectionSchema = z.object({
|
|||
.nullable()
|
||||
.optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
// Executor configuration
|
||||
executorType: ExecutorTypeSchema.nullable().optional(),
|
||||
executorConfig: ExecutorConfigUpdateSchema,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
146
packages/types/src/executor.ts
Normal file
146
packages/types/src/executor.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* Executor API Types
|
||||
*
|
||||
* These types define the contract between TPMJS and any executor service.
|
||||
* Custom executors must implement the ExecuteToolRequest/Response interface.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// =============================================================================
|
||||
// Executor API Specification
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Request payload for POST /execute-tool
|
||||
*/
|
||||
export interface ExecuteToolRequest {
|
||||
/** NPM package name, e.g., "@tpmjs/hello" */
|
||||
packageName: string;
|
||||
/** Tool name within the package, e.g., "helloWorld" */
|
||||
name: string;
|
||||
/** Package version, e.g., "1.0.0" or "latest" */
|
||||
version?: string;
|
||||
/** Direct esm.sh URL override for the package */
|
||||
importUrl?: string;
|
||||
/** Tool parameters to pass to execute() */
|
||||
params: Record<string, unknown>;
|
||||
/** Environment variables to inject during execution */
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from POST /execute-tool
|
||||
*/
|
||||
export interface ExecuteToolResponse {
|
||||
/** Whether the execution succeeded */
|
||||
success: boolean;
|
||||
/** Tool output on success */
|
||||
output?: unknown;
|
||||
/** Error message on failure */
|
||||
error?: string;
|
||||
/** Execution duration in milliseconds */
|
||||
executionTimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET /health (optional but recommended)
|
||||
*/
|
||||
export interface ExecutorHealthResponse {
|
||||
/** Executor status */
|
||||
status: 'ok' | 'degraded' | 'error';
|
||||
/** Executor version string */
|
||||
version?: string;
|
||||
/** Optional additional info */
|
||||
info?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Executor Configuration Schemas
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Executor type enum
|
||||
*/
|
||||
export const ExecutorTypeSchema = z.enum(['default', 'custom_url']);
|
||||
export type ExecutorType = z.infer<typeof ExecutorTypeSchema>;
|
||||
|
||||
/**
|
||||
* Default executor config (uses TPMJS Railway executor)
|
||||
*/
|
||||
export const DefaultExecutorConfigSchema = z.object({
|
||||
type: z.literal('default'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Custom URL executor config
|
||||
*/
|
||||
export const CustomUrlExecutorConfigSchema = z.object({
|
||||
type: z.literal('custom_url'),
|
||||
/** URL of the custom executor (must be HTTPS in production) */
|
||||
url: z.string().url(),
|
||||
/** Optional API key for Bearer token authentication */
|
||||
apiKey: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Union of all executor config types
|
||||
*/
|
||||
export const ExecutorConfigSchema = z.discriminatedUnion('type', [
|
||||
DefaultExecutorConfigSchema,
|
||||
CustomUrlExecutorConfigSchema,
|
||||
]);
|
||||
|
||||
export type ExecutorConfig = z.infer<typeof ExecutorConfigSchema>;
|
||||
export type DefaultExecutorConfig = z.infer<typeof DefaultExecutorConfigSchema>;
|
||||
export type CustomUrlExecutorConfig = z.infer<typeof CustomUrlExecutorConfigSchema>;
|
||||
|
||||
// =============================================================================
|
||||
// Zod Schemas for Request/Response Validation
|
||||
// =============================================================================
|
||||
|
||||
export const ExecuteToolRequestSchema = z.object({
|
||||
packageName: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
version: z.string().optional(),
|
||||
importUrl: z.string().url().optional(),
|
||||
params: z.record(z.string(), z.unknown()),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ExecuteToolResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.unknown().optional(),
|
||||
error: z.string().optional(),
|
||||
executionTimeMs: z.number(),
|
||||
});
|
||||
|
||||
export const ExecutorHealthResponseSchema = z.object({
|
||||
status: z.enum(['ok', 'degraded', 'error']),
|
||||
version: z.string().optional(),
|
||||
info: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Executor Verification
|
||||
// =============================================================================
|
||||
|
||||
export const VerifyExecutorRequestSchema = z.object({
|
||||
url: z.string().url(),
|
||||
apiKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface VerifyExecutorRequest {
|
||||
url: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface VerifyExecutorResponse {
|
||||
valid: boolean;
|
||||
healthCheck?: ExecutorHealthResponse;
|
||||
testExecution?: {
|
||||
success: boolean;
|
||||
executionTimeMs: number;
|
||||
};
|
||||
errors?: string[];
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ export default defineConfig({
|
|||
'src/collection.ts',
|
||||
'src/agent.ts',
|
||||
'src/user.ts',
|
||||
'src/executor.ts',
|
||||
],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
|
|
|
|||
110
templates/vercel-executor/README.md
Normal file
110
templates/vercel-executor/README.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# TPMJS Executor Template
|
||||
|
||||
Deploy your own executor for running TPMJS tools on Vercel.
|
||||
|
||||
## One-Click Deploy
|
||||
|
||||
[](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)
|
||||
|
||||
## What is an Executor?
|
||||
|
||||
An executor is a service that runs TPMJS tools. By default, TPMJS uses a shared executor, but you can deploy your own for:
|
||||
|
||||
- **Full control**: Run tools on your own infrastructure
|
||||
- **Custom environment**: Inject your own environment variables and secrets
|
||||
- **Privacy**: Keep tool execution data on your own servers
|
||||
- **Performance**: Deploy in regions closest to your users
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /api/execute-tool
|
||||
|
||||
Execute a TPMJS tool.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"packageName": "@tpmjs/hello",
|
||||
"name": "helloWorld",
|
||||
"version": "latest",
|
||||
"params": { "name": "World" },
|
||||
"env": { "MY_SECRET": "value" }
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"output": "Hello, World!",
|
||||
"executionTimeMs": 123
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/health
|
||||
|
||||
Check executor health status.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"info": {
|
||||
"runtime": "vercel-serverless",
|
||||
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `EXECUTOR_API_KEY` | No | API key for authentication. If set, requests must include `Authorization: Bearer <key>` header. |
|
||||
|
||||
### Setting Up API Key Authentication
|
||||
|
||||
1. Go to your Vercel project settings
|
||||
2. Add an environment variable: `EXECUTOR_API_KEY` with a secure random value
|
||||
3. When configuring your executor in TPMJS, enter this key in the "API Key" field
|
||||
|
||||
## How It Works
|
||||
|
||||
1. TPMJS sends a request to your executor with package name, tool name, and parameters
|
||||
2. The executor dynamically imports the package from [esm.sh](https://esm.sh)
|
||||
3. The tool's `execute()` function is called with the provided parameters
|
||||
4. The result is returned to TPMJS
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
|
||||
# Test the health endpoint
|
||||
curl http://localhost:3000/api/health
|
||||
|
||||
# Test tool execution
|
||||
curl -X POST http://localhost:3000/api/execute-tool \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName":"@anthropic-ai/tpmjs-hello","name":"helloWorld","params":{"name":"Test"}}'
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Always set `EXECUTOR_API_KEY` in production to prevent unauthorized access
|
||||
- The executor runs tools in a serverless environment with limited capabilities
|
||||
- Environment variables injected via `env` are available only during execution
|
||||
- Tools are imported from esm.sh, a trusted CDN for npm packages
|
||||
|
||||
## Support
|
||||
|
||||
- [TPMJS Documentation](https://tpmjs.com/docs)
|
||||
- [Executor Documentation](https://tpmjs.com/docs/executors)
|
||||
- [GitHub Issues](https://github.com/tpmjs/tpmjs/issues)
|
||||
112
templates/vercel-executor/app/api/execute-tool/route.ts
Normal file
112
templates/vercel-executor/app/api/execute-tool/route.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Execute Tool Endpoint
|
||||
*
|
||||
* POST /api/execute-tool
|
||||
* Executes a TPMJS tool with the provided parameters
|
||||
*/
|
||||
|
||||
import { type ExecuteToolRequest, executeTool } from '@/lib/executor';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60; // 60 seconds max
|
||||
|
||||
/**
|
||||
* Verify API key if configured
|
||||
*/
|
||||
function verifyApiKey(request: NextRequest): boolean {
|
||||
const apiKey = process.env.EXECUTOR_API_KEY;
|
||||
|
||||
// If no API key is configured, allow all requests
|
||||
if (!apiKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Expect "Bearer <api-key>" format
|
||||
const [type, token] = authHeader.split(' ');
|
||||
if (type !== 'Bearer' || token !== apiKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the request body
|
||||
*/
|
||||
function validateRequest(body: unknown): body is ExecuteToolRequest {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const req = body as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
typeof req.packageName === 'string' &&
|
||||
req.packageName.length > 0 &&
|
||||
typeof req.name === 'string' &&
|
||||
req.name.length > 0 &&
|
||||
typeof req.params === 'object' &&
|
||||
req.params !== null
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify API key if configured
|
||||
if (!verifyApiKey(request)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized', executionTimeMs: 0 },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate request
|
||||
if (!validateRequest(body)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request. Required: packageName, name, params',
|
||||
executionTimeMs: 0,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
const result = await executeTool(body);
|
||||
|
||||
return NextResponse.json(result, {
|
||||
status: result.success ? 200 : 500,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
executionTimeMs: 0,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OPTIONS for CORS preflight
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
}
|
||||
40
templates/vercel-executor/app/api/health/route.ts
Normal file
40
templates/vercel-executor/app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Health Check Endpoint
|
||||
*
|
||||
* GET /api/health
|
||||
* Returns the health status of the executor
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface HealthResponse {
|
||||
status: 'ok' | 'degraded' | 'error';
|
||||
version?: string;
|
||||
info?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse<HealthResponse>> {
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
version: '1.0.0',
|
||||
info: {
|
||||
runtime: 'vercel-serverless',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle OPTIONS for CORS preflight
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
}
|
||||
11
templates/vercel-executor/app/layout.tsx
Normal file
11
templates/vercel-executor/app/layout.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
20
templates/vercel-executor/app/page.tsx
Normal file
20
templates/vercel-executor/app/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export default function Home() {
|
||||
return (
|
||||
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
||||
<h1>TPMJS Executor</h1>
|
||||
<p>This is a TPMJS tool executor service.</p>
|
||||
<h2>Endpoints</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<code>GET /api/health</code> - Health check
|
||||
</li>
|
||||
<li>
|
||||
<code>POST /api/execute-tool</code> - Execute a tool
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<a href="https://tpmjs.com/docs/executors">Documentation</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
templates/vercel-executor/lib/executor.ts
Normal file
101
templates/vercel-executor/lib/executor.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* TPMJS Executor - Core Execution Logic
|
||||
*
|
||||
* This module handles dynamic import and execution of TPMJS tools.
|
||||
* Tools are loaded from esm.sh and executed in a serverless environment.
|
||||
*/
|
||||
|
||||
export interface ExecuteToolRequest {
|
||||
/** NPM package name, e.g., "@tpmjs/hello" */
|
||||
packageName: string;
|
||||
/** Tool name within the package, e.g., "helloWorld" */
|
||||
name: string;
|
||||
/** Package version, e.g., "1.0.0" or "latest" */
|
||||
version?: string;
|
||||
/** Direct esm.sh URL override for the package */
|
||||
importUrl?: string;
|
||||
/** Tool parameters to pass to execute() */
|
||||
params: Record<string, unknown>;
|
||||
/** Environment variables to inject during execution */
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ExecuteToolResponse {
|
||||
/** Whether the execution succeeded */
|
||||
success: boolean;
|
||||
/** Tool output on success */
|
||||
output?: unknown;
|
||||
/** Error message on failure */
|
||||
error?: string;
|
||||
/** Execution duration in milliseconds */
|
||||
executionTimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the esm.sh URL for a package
|
||||
*/
|
||||
function buildEsmUrl(packageName: string, version?: string): string {
|
||||
const resolvedVersion = version || 'latest';
|
||||
return `https://esm.sh/${packageName}@${resolvedVersion}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a TPMJS tool
|
||||
*
|
||||
* @param request - Execution request with package name, tool name, and parameters
|
||||
* @returns Execution result
|
||||
*/
|
||||
export async function executeTool(request: ExecuteToolRequest): Promise<ExecuteToolResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Build the import URL
|
||||
const importUrl = request.importUrl || buildEsmUrl(request.packageName, request.version);
|
||||
|
||||
// Dynamically import the package from esm.sh
|
||||
const module = await import(/* webpackIgnore: true */ importUrl);
|
||||
|
||||
// Get the tool export
|
||||
// Try the specific tool name first, then fall back to default export
|
||||
const tool = module[request.name] || module.default;
|
||||
|
||||
if (!tool) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Tool '${request.name}' not found in package '${request.packageName}'`,
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure the tool has an execute function
|
||||
if (typeof tool.execute !== 'function') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Tool '${request.name}' does not have an execute() function`,
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Inject environment variables if provided
|
||||
if (request.env) {
|
||||
for (const [key, value] of Object.entries(request.env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
const output = await tool.execute(request.params);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output,
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
10
templates/vercel-executor/next.config.js
Normal file
10
templates/vercel-executor/next.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Enable experimental features for better serverless performance
|
||||
experimental: {
|
||||
// Allow dynamic imports from esm.sh
|
||||
serverActions: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
21
templates/vercel-executor/package.json
Normal file
21
templates/vercel-executor/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "tpmjs-executor",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "TPMJS Tool Executor - Deploy your own executor for running TPMJS tools",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
22
templates/vercel-executor/tsconfig.json
Normal file
22
templates/vercel-executor/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
14
templates/vercel-executor/vercel.json
Normal file
14
templates/vercel-executor/vercel.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"framework": "nextjs",
|
||||
"headers": [
|
||||
{
|
||||
"source": "/api/(.*)",
|
||||
"headers": [
|
||||
{ "key": "Access-Control-Allow-Origin", "value": "*" },
|
||||
{ "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" },
|
||||
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue