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 { UpdateAgentSchema } from '@tpmjs/types/agent';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import type { NextRequest } from 'next/server';
|
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({
|
const agent = await prisma.agent.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: parsed.data,
|
data: updateData,
|
||||||
select: {
|
include: {
|
||||||
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,
|
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
tools: true,
|
tools: true,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { prisma } from '@tpmjs/db';
|
import { Prisma, prisma } from '@tpmjs/db';
|
||||||
import { UpdateCollectionSchema } from '@tpmjs/types/collection';
|
import { UpdateCollectionSchema } from '@tpmjs/types/collection';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { type NextRequest, NextResponse } from 'next/server';
|
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 is being changed, check for duplicates
|
||||||
if (name && name !== existingCollection.name) {
|
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({
|
const collection = await prisma.collection.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...(name !== undefined && { name }),
|
...(name !== undefined && { name }),
|
||||||
...(description !== undefined && { description }),
|
...(description !== undefined && { description }),
|
||||||
...(isPublic !== undefined && { isPublic }),
|
...(isPublic !== undefined && { isPublic }),
|
||||||
|
...(executorType !== undefined && { executorType }),
|
||||||
|
...(executorConfig !== undefined && {
|
||||||
|
executorConfig: executorConfig === null ? Prisma.JsonNull : executorConfig,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { tools: true } },
|
_count: { select: { tools: true } },
|
||||||
|
|
@ -300,6 +304,8 @@ export async function PATCH(
|
||||||
description: collection.description,
|
description: collection.description,
|
||||||
isPublic: collection.isPublic,
|
isPublic: collection.isPublic,
|
||||||
toolCount: collection._count.tools,
|
toolCount: collection._count.tools,
|
||||||
|
executorType: collection.executorType,
|
||||||
|
executorConfig: collection.executorConfig,
|
||||||
createdAt: collection.createdAt,
|
createdAt: collection.createdAt,
|
||||||
updatedAt: collection.updatedAt,
|
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 type { AIProvider } from '@tpmjs/types/agent';
|
||||||
import { PROVIDER_MODELS, SUPPORTED_PROVIDERS } 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 { Button } from '@tpmjs/ui/Button/Button';
|
||||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
|
|
@ -19,6 +20,7 @@ import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
|
||||||
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
|
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
|
||||||
|
|
||||||
interface Agent {
|
interface Agent {
|
||||||
|
|
@ -33,6 +35,8 @@ interface Agent {
|
||||||
maxToolCallsPerTurn: number;
|
maxToolCallsPerTurn: number;
|
||||||
maxMessagesInContext: number;
|
maxMessagesInContext: number;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
|
executorType: string | null;
|
||||||
|
executorConfig: { url: string; apiKey?: string } | null;
|
||||||
toolCount: number;
|
toolCount: number;
|
||||||
collectionCount: number;
|
collectionCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -267,6 +271,7 @@ export default function AgentDetailPage(): React.ReactElement {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [executorConfig, setExecutorConfig] = useState<ExecutorConfig | null>(null);
|
||||||
|
|
||||||
// Tools state
|
// Tools state
|
||||||
const [agentTools, setAgentTools] = useState<AgentTool[]>([]);
|
const [agentTools, setAgentTools] = useState<AgentTool[]>([]);
|
||||||
|
|
@ -316,6 +321,16 @@ export default function AgentDetailPage(): React.ReactElement {
|
||||||
maxMessagesInContext: data.data.maxMessagesInContext,
|
maxMessagesInContext: data.data.maxMessagesInContext,
|
||||||
isPublic: data.data.isPublic,
|
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 {
|
} else {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
router.push('/sign-in');
|
router.push('/sign-in');
|
||||||
|
|
@ -576,19 +591,39 @@ export default function AgentDetailPage(): React.ReactElement {
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/agents/${agentId}`, {
|
const response = await fetch(`/api/agents/${agentId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(updatePayload),
|
||||||
...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,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
@ -863,6 +898,15 @@ export default function AgentDetailPage(): React.ReactElement {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-end gap-2 pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -880,6 +924,16 @@ export default function AgentDetailPage(): React.ReactElement {
|
||||||
maxMessagesInContext: agent.maxMessagesInContext,
|
maxMessagesInContext: agent.maxMessagesInContext,
|
||||||
isPublic: agent.isPublic,
|
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
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import type { ExecutorConfig } from '@tpmjs/types/executor';
|
||||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||||
import { Button } from '@tpmjs/ui/Button/Button';
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
|
||||||
import { AddToolSearch } from '~/components/collections/AddToolSearch';
|
import { AddToolSearch } from '~/components/collections/AddToolSearch';
|
||||||
import { CollectionForm } from '~/components/collections/CollectionForm';
|
import { CollectionForm } from '~/components/collections/CollectionForm';
|
||||||
import { CollectionToolList } from '~/components/collections/CollectionToolList';
|
import { CollectionToolList } from '~/components/collections/CollectionToolList';
|
||||||
|
|
@ -165,12 +167,15 @@ interface Collection {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
toolCount: number;
|
toolCount: number;
|
||||||
|
executorType: string | null;
|
||||||
|
executorConfig: { url: string; apiKey?: string } | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
tools: CollectionTool[];
|
tools: CollectionTool[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Page component with multiple handlers
|
||||||
export default function CollectionDetailPage(): React.ReactElement {
|
export default function CollectionDetailPage(): React.ReactElement {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -183,7 +188,9 @@ export default function CollectionDetailPage(): React.ReactElement {
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [removingToolId, setRemovingToolId] = useState<string | null>(null);
|
const [removingToolId, setRemovingToolId] = useState<string | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
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 () => {
|
const fetchCollection = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/collections/${collectionId}`);
|
const response = await fetch(`/api/collections/${collectionId}`);
|
||||||
|
|
@ -191,6 +198,16 @@ export default function CollectionDetailPage(): React.ReactElement {
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setCollection(data.data);
|
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 {
|
} else {
|
||||||
if (data.error?.code === 'UNAUTHORIZED') {
|
if (data.error?.code === 'UNAUTHORIZED') {
|
||||||
router.push('/sign-in');
|
router.push('/sign-in');
|
||||||
|
|
@ -218,11 +235,28 @@ export default function CollectionDetailPage(): React.ReactElement {
|
||||||
if (!collection) return;
|
if (!collection) return;
|
||||||
setIsUpdating(true);
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/collections/${collectionId}`, {
|
const response = await fetch(`/api/collections/${collectionId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(updatePayload),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
@ -235,6 +269,8 @@ export default function CollectionDetailPage(): React.ReactElement {
|
||||||
name: result.data.name,
|
name: result.data.name,
|
||||||
description: result.data.description,
|
description: result.data.description,
|
||||||
isPublic: result.data.isPublic,
|
isPublic: result.data.isPublic,
|
||||||
|
executorType: result.data.executorType,
|
||||||
|
executorConfig: result.data.executorConfig,
|
||||||
updatedAt: result.data.updatedAt,
|
updatedAt: result.data.updatedAt,
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
|
|
@ -430,6 +466,15 @@ export default function CollectionDetailPage(): React.ReactElement {
|
||||||
isSubmitting={isUpdating}
|
isSubmitting={isUpdating}
|
||||||
submitLabel="Save Changes"
|
submitLabel="Save Changes"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Executor Configuration */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-border">
|
||||||
|
<ExecutorConfigPanel
|
||||||
|
value={executorConfig}
|
||||||
|
onChange={setExecutorConfig}
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</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 { prisma } from '@tpmjs/db';
|
||||||
|
|
||||||
import { createToolDefinition } from '../ai-agent/tool-executor-agent';
|
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 & {
|
type AgentWithRelations = Agent & {
|
||||||
collections: (AgentCollection & {
|
collections: (AgentCollection & {
|
||||||
collection: Collection & {
|
collection: Collection & {
|
||||||
|
|
@ -22,6 +24,7 @@ type AgentWithRelations = Agent & {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a full agent with all tool relations
|
* Fetch a full agent with all tool relations
|
||||||
|
* Includes executor config for cascade resolution
|
||||||
*/
|
*/
|
||||||
export async function fetchAgentWithTools(agentId: string): Promise<AgentWithRelations | null> {
|
export async function fetchAgentWithTools(agentId: string): Promise<AgentWithRelations | null> {
|
||||||
return prisma.agent.findUnique({
|
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
|
* Fetch an agent by UID with all tool relations
|
||||||
|
* Includes executor config for cascade resolution
|
||||||
*/
|
*/
|
||||||
export async function fetchAgentByUidWithTools(uid: string): Promise<AgentWithRelations | null> {
|
export async function fetchAgentByUidWithTools(uid: string): Promise<AgentWithRelations | null> {
|
||||||
return prisma.agent.findUnique({
|
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
|
* Fetch an agent by ID or UID with all tool relations
|
||||||
* Accepts either the cuid or the user-friendly uid
|
* Accepts either the cuid or the user-friendly uid
|
||||||
|
* Includes executor config for cascade resolution
|
||||||
*/
|
*/
|
||||||
export async function fetchAgentByIdOrUidWithTools(
|
export async function fetchAgentByIdOrUidWithTools(
|
||||||
idOrUid: string
|
idOrUid: string
|
||||||
|
|
@ -136,6 +141,7 @@ export async function fetchAgentByIdOrUidWithTools(
|
||||||
/**
|
/**
|
||||||
* Fetch an agent by username and uid with all tool relations
|
* Fetch an agent by username and uid with all tool relations
|
||||||
* Uses the username/uid pretty URL format
|
* Uses the username/uid pretty URL format
|
||||||
|
* Includes executor config for cascade resolution
|
||||||
*/
|
*/
|
||||||
export async function fetchAgentByUsernameAndUidWithTools(
|
export async function fetchAgentByUsernameAndUidWithTools(
|
||||||
username: string,
|
username: string,
|
||||||
|
|
@ -189,6 +195,11 @@ function sanitizeToolName(name: string): string {
|
||||||
/**
|
/**
|
||||||
* Build all tools from an agent's collections and individual tools
|
* Build all tools from an agent's collections and individual tools
|
||||||
* Returns a map of tool name -> AI SDK tool definition
|
* 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(
|
export function buildAgentTools(
|
||||||
agent: AgentWithRelations
|
agent: AgentWithRelations
|
||||||
|
|
@ -196,9 +207,23 @@ export function buildAgentTools(
|
||||||
const tools: Record<string, ReturnType<typeof createToolDefinition>> = {};
|
const tools: Record<string, ReturnType<typeof createToolDefinition>> = {};
|
||||||
const seenTools = new Set<string>();
|
const seenTools = new Set<string>();
|
||||||
|
|
||||||
|
// Parse agent-level executor config
|
||||||
|
const agentExecutorConfig = parseExecutorConfig(agent.executorType, agent.executorConfig);
|
||||||
|
|
||||||
// Add tools from collections first
|
// Add tools from collections first
|
||||||
for (const agentCollection of agent.collections) {
|
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 tool = collectionTool.tool;
|
||||||
const toolKey = `${tool.package.npmPackageName}::${tool.name}`;
|
const toolKey = `${tool.package.npmPackageName}::${tool.name}`;
|
||||||
|
|
||||||
|
|
@ -207,11 +232,14 @@ export function buildAgentTools(
|
||||||
seenTools.add(toolKey);
|
seenTools.add(toolKey);
|
||||||
|
|
||||||
const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`);
|
const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`);
|
||||||
tools[toolName] = createToolDefinition(tool);
|
tools[toolName] = createToolDefinition(tool, resolvedConfig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add individual tools (may override collection tools)
|
// 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) {
|
for (const agentTool of agent.tools) {
|
||||||
const tool = agentTool.tool;
|
const tool = agentTool.tool;
|
||||||
const toolKey = `${tool.package.npmPackageName}::${tool.name}`;
|
const toolKey = `${tool.package.npmPackageName}::${tool.name}`;
|
||||||
|
|
@ -221,7 +249,7 @@ export function buildAgentTools(
|
||||||
seenTools.add(toolKey);
|
seenTools.add(toolKey);
|
||||||
|
|
||||||
const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`);
|
const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`);
|
||||||
tools[toolName] = createToolDefinition(tool);
|
tools[toolName] = createToolDefinition(tool, individualToolConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tools;
|
return tools;
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@
|
||||||
|
|
||||||
import { openai } from '@ai-sdk/openai';
|
import { openai } from '@ai-sdk/openai';
|
||||||
import type { Package, Tool } from '@tpmjs/db';
|
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 { type ModelMessage, generateText } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { executeWithExecutor } from '../executors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameter from TPMJS metadata
|
* Parameter from TPMJS metadata
|
||||||
*/
|
*/
|
||||||
|
|
@ -91,8 +93,14 @@ export function tpmjsParamsToZodSchema(parameters: TPMJSParameter[]): z.ZodObjec
|
||||||
/**
|
/**
|
||||||
* Create AI SDK v6 tool definition from TPMJS Tool
|
* Create AI SDK v6 tool definition from TPMJS Tool
|
||||||
* Requires Tool with Package relation
|
* 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)
|
const parameters = Array.isArray(tool.parameters)
|
||||||
? (tool.parameters as unknown as TPMJSParameter[])
|
? (tool.parameters as unknown as TPMJSParameter[])
|
||||||
: [];
|
: [];
|
||||||
|
|
@ -118,14 +126,13 @@ export function createToolDefinition(tool: Tool & { package: Package }) {
|
||||||
execute: async (params: Record<string, unknown>) => {
|
execute: async (params: Record<string, unknown>) => {
|
||||||
console.log('[Tool execute] Running:', sanitizedName, params);
|
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
|
// Use the actual export name from the Tool record
|
||||||
const result = await executePackage(
|
const result = await executeWithExecutor(executorConfig ?? null, {
|
||||||
tool.package.npmPackageName,
|
packageName: tool.package.npmPackageName,
|
||||||
tool.name, // Use actual export name (e.g., "helloWorldTool", "default")
|
name: tool.name, // Use actual export name (e.g., "helloWorldTool", "default")
|
||||||
params,
|
params,
|
||||||
{ timeout: 5000 }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Package execution failed');
|
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 { prisma } from '@tpmjs/db';
|
||||||
|
|
||||||
|
import { executeWithExecutor, parseExecutorConfig } from '../executors';
|
||||||
import { convertToMcpTool, parseToolName } from './tool-converter';
|
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;
|
type JsonRpcId = string | number | null;
|
||||||
|
|
||||||
interface JsonRpcResponse {
|
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({
|
const collection = await prisma.collection.findUnique({
|
||||||
where: { id: collectionId },
|
where: { id: collectionId },
|
||||||
include: {
|
select: {
|
||||||
|
executorType: true,
|
||||||
|
executorConfig: true,
|
||||||
tools: {
|
tools: {
|
||||||
include: { tool: { include: { package: true } } },
|
include: { tool: { include: { package: true } } },
|
||||||
},
|
},
|
||||||
|
|
@ -137,12 +103,15 @@ export async function handleToolsCall(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute via sandbox (direct call with hardcoded URL)
|
// Resolve executor configuration (collection config only for MCP - no agent context)
|
||||||
const result = await executePackageDirect(
|
const executorConfig = parseExecutorConfig(collection?.executorType, collection?.executorConfig);
|
||||||
parsed.packageName,
|
|
||||||
parsed.toolName,
|
// Execute via resolved executor
|
||||||
params.arguments ?? {}
|
const result = await executeWithExecutor(executorConfig, {
|
||||||
);
|
packageName: parsed.packageName,
|
||||||
|
name: parsed.toolName,
|
||||||
|
params: params.arguments ?? {},
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -422,6 +422,10 @@ model Collection {
|
||||||
isPublic Boolean @default(false) @map("is_public")
|
isPublic Boolean @default(false) @map("is_public")
|
||||||
likeCount Int @default(0) @map("like_count")
|
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
|
// Timestamps
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
@ -513,6 +517,11 @@ model Agent {
|
||||||
isPublic Boolean @default(true) @map("is_public")
|
isPublic Boolean @default(true) @map("is_public")
|
||||||
likeCount Int @default(0) @map("like_count")
|
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
|
// Timestamps
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@
|
||||||
"./user": {
|
"./user": {
|
||||||
"types": "./dist/user.d.ts",
|
"types": "./dist/user.d.ts",
|
||||||
"default": "./dist/user.js"
|
"default": "./dist/user.js"
|
||||||
|
},
|
||||||
|
"./executor": {
|
||||||
|
"types": "./dist/executor.d.ts",
|
||||||
|
"default": "./dist/executor.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": ["dist"],
|
"files": ["dist"],
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ExecutorTypeSchema } from './executor';
|
||||||
|
|
||||||
// Regex for valid agent UID: lowercase alphanumeric and hyphens
|
// Regex for valid agent UID: lowercase alphanumeric and hyphens
|
||||||
const UID_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
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
|
// Enums
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -49,6 +60,9 @@ export const UpdateAgentSchema = z.object({
|
||||||
maxToolCallsPerTurn: z.number().int().min(1).max(100).optional(),
|
maxToolCallsPerTurn: z.number().int().min(1).max(100).optional(),
|
||||||
maxMessagesInContext: z.number().int().min(1).max(100).optional(),
|
maxMessagesInContext: z.number().int().min(1).max(100).optional(),
|
||||||
isPublic: z.boolean().optional(),
|
isPublic: z.boolean().optional(),
|
||||||
|
// Executor configuration
|
||||||
|
executorType: ExecutorTypeSchema.nullable().optional(),
|
||||||
|
executorConfig: ExecutorConfigUpdateSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AddCollectionToAgentSchema = z.object({
|
export const AddCollectionToAgentSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ExecutorTypeSchema } from './executor';
|
||||||
|
|
||||||
// Regex for valid collection names: letters, numbers, spaces, hyphens, underscores
|
// Regex for valid collection names: letters, numbers, spaces, hyphens, underscores
|
||||||
const NAME_REGEX = /^[a-zA-Z0-9\s\-_]+$/;
|
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
|
// Collection Schemas
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -30,6 +41,9 @@ export const UpdateCollectionSchema = z.object({
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
isPublic: z.boolean().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/collection.ts',
|
||||||
'src/agent.ts',
|
'src/agent.ts',
|
||||||
'src/user.ts',
|
'src/user.ts',
|
||||||
|
'src/executor.ts',
|
||||||
],
|
],
|
||||||
format: ['esm'],
|
format: ['esm'],
|
||||||
dts: true,
|
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