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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue