diff --git a/src/app/api/sandbox/validate/route.ts b/src/app/api/sandbox/validate/route.ts new file mode 100644 index 00000000..ca45b6fe --- /dev/null +++ b/src/app/api/sandbox/validate/route.ts @@ -0,0 +1,163 @@ +import { NextRequest, NextResponse } from "next/server"; + +type ValidationResponse = { + valid: boolean; + errors: string[]; + warnings: string[]; + score: number; +}; + +const REQUIRED_FIELDS = ["name", "description", "version", "capabilities"] as const; +const OPTIONAL_FIELDS = ["identity", "endpoints", "protocols", "trust"] as const; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function clampScore(value: number) { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function validateAgentJson(agentJson: string): ValidationResponse { + const errors: string[] = []; + const warnings: string[] = []; + + if (!isNonEmptyString(agentJson)) { + return { + valid: false, + errors: ["agentJson is required and must be a non-empty JSON string."], + warnings, + score: 0, + }; + } + + let parsed: Record; + try { + const json = JSON.parse(agentJson) as unknown; + if (!json || typeof json !== "object" || Array.isArray(json)) { + return { + valid: false, + errors: ["agentJson must parse to a JSON object."], + warnings, + score: 0, + }; + } + parsed = json as Record; + } catch (error) { + return { + valid: false, + errors: [ + `Invalid JSON: ${error instanceof Error ? error.message : "Unable to parse provided JSON."}`, + ], + warnings, + score: 0, + }; + } + + let score = 0; + + for (const field of REQUIRED_FIELDS) { + const value = parsed[field]; + + if (value === undefined) { + errors.push(`Missing required field: ${field}`); + continue; + } + + if (field === "capabilities") { + if (!Array.isArray(value) || value.length === 0) { + errors.push("Field 'capabilities' must be a non-empty array."); + continue; + } + + const invalidCapability = value.find((capability) => !isNonEmptyString(capability)); + if (invalidCapability !== undefined) { + errors.push("Field 'capabilities' must contain non-empty strings only."); + continue; + } + + score += 15; + continue; + } + + if (!isNonEmptyString(value)) { + errors.push(`Field '${field}' must be a non-empty string.`); + continue; + } + + if (field === "version") { + const semverPattern = /^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$/; + if (!semverPattern.test(value)) { + warnings.push("Field 'version' should follow semver format (e.g. 1.0.0)."); + } + } + + score += 15; + } + + for (const field of OPTIONAL_FIELDS) { + const value = parsed[field]; + + if (value === undefined) { + warnings.push(`Optional field '${field}' is missing.`); + continue; + } + + if (!value || typeof value !== "object" || Array.isArray(value)) { + warnings.push(`Optional field '${field}' should be an object.`); + continue; + } + + if (field === "endpoints") { + const endpointEntries = Object.entries(value as Record); + if (endpointEntries.length === 0) { + warnings.push("Optional field 'endpoints' is present but empty."); + } + + const invalidEndpoint = endpointEntries.find(([, endpoint]) => !isNonEmptyString(endpoint)); + if (invalidEndpoint) { + warnings.push("Optional field 'endpoints' should contain URL strings as values."); + } + } + + score += 10; + } + + return { + valid: errors.length === 0, + errors, + warnings, + score: clampScore(score), + }; +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as { agentJson?: unknown }; + + if (typeof body?.agentJson !== "string") { + return NextResponse.json( + { + valid: false, + errors: ["Request body must include { agentJson: string }."], + warnings: [], + score: 0, + } satisfies ValidationResponse, + { status: 400 }, + ); + } + + const result = validateAgentJson(body.agentJson); + return NextResponse.json(result, { status: 200 }); + } catch { + return NextResponse.json( + { + valid: false, + errors: ["Invalid request body. Expected JSON payload with { agentJson: string }."], + warnings: [], + score: 0, + } satisfies ValidationResponse, + { status: 400 }, + ); + } +} diff --git a/src/app/sandbox/SandboxClient.tsx b/src/app/sandbox/SandboxClient.tsx index fd353aaf..1a237eed 100644 --- a/src/app/sandbox/SandboxClient.tsx +++ b/src/app/sandbox/SandboxClient.tsx @@ -1,242 +1,96 @@ +/* eslint-disable react/no-unescaped-entities */ "use client"; -import { useState, useMemo } from "react"; +import { useMemo, useState } from "react"; +import templatesData from "@/data/agent-templates.json"; import { Button } from "@/components/ui/button"; type ValidationResult = { valid: boolean; errors: string[]; warnings: string[]; + score: number; }; -type EndpointTest = { - url: string; - reachable: boolean; - reason?: string; +type AgentTemplate = { + id: string; + name: string; + description: string; + config: Record; }; -const SAMPLE_TEMPLATES = { - basic: { - name: "Basic Agent", - config: { - name: "My Agent", - version: "1.0.0", - description: "A simple agent configuration", - capabilities: ["chat", "search"], - endpoints: { - chat: "https://api.example.com/chat", - }, - }, - }, - mcp: { - name: "MCP-Enabled Agent", - config: { - name: "MCP Agent", - version: "1.0.0", - description: "Agent with Model Context Protocol support", - capabilities: ["chat", "mcp"], - endpoints: { - chat: "https://api.example.com/chat", - mcp: "https://api.example.com/mcp", - }, - mcp: { - version: "2024-11-05", - servers: [ - { - name: "filesystem", - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], - }, - ], - }, - }, - }, - multiTool: { - name: "Multi-Tool Agent", - config: { - name: "Multi-Tool Agent", - version: "1.0.0", - description: "Agent with multiple tool integrations", - capabilities: ["chat", "search", "code", "browse"], - endpoints: { - chat: "https://api.example.com/chat", - search: "https://api.example.com/search", - code: "https://api.example.com/code", - }, - tools: [ - { - name: "web_search", - type: "builtin", - }, - { - name: "code_interpreter", - type: "custom", - endpoint: "https://api.example.com/tools/code", - }, - ], - }, - }, -}; - -function validateAgentConfig(configText: string): ValidationResult { - const result: ValidationResult = { - valid: true, - errors: [], - warnings: [], - }; - - // Check if empty - if (!configText.trim()) { - result.valid = false; - result.errors.push("Configuration is empty"); - return result; - } - - // Check JSON syntax - let config: Record; - try { - config = JSON.parse(configText); - } catch (err) { - result.valid = false; - result.errors.push(`Invalid JSON: ${err instanceof Error ? err.message : "Parse error"}`); - return result; - } - - // Check required fields - const requiredFields = ["name", "version", "description"]; - for (const field of requiredFields) { - if (!(field in config)) { - result.valid = false; - result.errors.push(`Missing required field: ${field}`); - } else if (typeof config[field] !== "string") { - result.valid = false; - result.errors.push(`Field '${field}' must be a string`); - } else if (!(config[field] as string).trim()) { - result.valid = false; - result.errors.push(`Field '${field}' cannot be empty`); - } - } +const TEMPLATES = templatesData as AgentTemplate[]; - // Check capabilities (optional but recommended) - if (!("capabilities" in config)) { - result.warnings.push("No 'capabilities' field found (recommended)"); - } else if (!Array.isArray(config.capabilities)) { - result.errors.push("Field 'capabilities' must be an array"); - result.valid = false; - } +const EMPTY_RESULT: ValidationResult = { + valid: false, + errors: [], + warnings: [], + score: 0, +}; - // Check endpoints - if (!("endpoints" in config)) { - result.warnings.push("No 'endpoints' field found"); - } else if (typeof config.endpoints !== "object" || config.endpoints === null) { - result.errors.push("Field 'endpoints' must be an object"); - result.valid = false; - } else { - const endpoints = config.endpoints as Record; - if (Object.keys(endpoints).length === 0) { - result.warnings.push("No endpoints defined"); - } - } +export default function SandboxClient() { + const [configText, setConfigText] = useState(""); + const [validation, setValidation] = useState(null); + const [isValidating, setIsValidating] = useState(false); + const [requestError, setRequestError] = useState(null); + + const scoreColor = useMemo(() => { + const score = validation?.score ?? 0; + if (score >= 80) return "bg-emerald-500"; + if (score >= 50) return "bg-amber-500"; + return "bg-red-500"; + }, [validation?.score]); + + const statusColor = useMemo(() => { + if (!validation) return "text-slate-300"; + if (validation.valid && validation.warnings.length === 0) return "text-emerald-400"; + if (validation.valid) return "text-amber-400"; + return "text-red-400"; + }, [validation]); - // Version format check - if (config.version && typeof config.version === "string") { - const versionPattern = /^\d+\.\d+\.\d+$/; - if (!versionPattern.test(config.version)) { - result.warnings.push("Version should follow semver format (e.g., 1.0.0)"); - } - } + const handleTemplateChange = (e: React.ChangeEvent) => { + const templateId = e.target.value; + const selected = TEMPLATES.find((template) => template.id === templateId); - return result; -} + if (!selected) return; -function testEndpoints(configText: string): EndpointTest[] { - const results: EndpointTest[] = []; + setConfigText(JSON.stringify(selected.config, null, 2)); + setValidation(null); + setRequestError(null); + }; - try { - const config = JSON.parse(configText) as Record; - const endpoints = config.endpoints as Record | undefined; + const handleValidate = async () => { + setIsValidating(true); + setRequestError(null); - if (!endpoints || typeof endpoints !== "object") { - return results; - } + try { + const response = await fetch("/api/sandbox/validate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ agentJson: configText }), + }); - for (const [key, value] of Object.entries(endpoints)) { - if (typeof value !== "string") { - results.push({ - url: `${key}: ${String(value)}`, - reachable: false, - reason: "URL must be a string", - }); - continue; - } + const result = (await response.json()) as ValidationResult; - try { - const url = new URL(value); - - // Check if HTTPS (recommended) - if (url.protocol !== "https:") { - results.push({ - url: value, - reachable: true, - reason: "Warning: Not using HTTPS", - }); - } else { - results.push({ - url: value, - reachable: true, - }); - } - } catch { - results.push({ - url: value, - reachable: false, - reason: "Invalid URL format", - }); + if (!response.ok) { + setValidation(result ?? EMPTY_RESULT); + setRequestError("Validation request failed. Please check your JSON and try again."); + return; } - } - } catch { - // JSON parse error - already handled by validation - } - - return results; -} -export default function SandboxClient() { - const [configText, setConfigText] = useState(""); - const [validation, setValidation] = useState(null); - const [endpointTests, setEndpointTests] = useState([]); - - const handleValidate = () => { - const result = validateAgentConfig(configText); - setValidation(result); - setEndpointTests([]); - }; - - const handleTestEndpoints = () => { - const tests = testEndpoints(configText); - setEndpointTests(tests); - }; - - const handleTemplateChange = (e: React.ChangeEvent) => { - const template = e.target.value; - if (template && template in SAMPLE_TEMPLATES) { - const config = SAMPLE_TEMPLATES[template as keyof typeof SAMPLE_TEMPLATES].config; - setConfigText(JSON.stringify(config, null, 2)); - setValidation(null); - setEndpointTests([]); + setValidation(result); + } catch { + setValidation(EMPTY_RESULT); + setRequestError("Could not reach validator endpoint. Please try again."); + } finally { + setIsValidating(false); } }; - const validationColor = useMemo(() => { - if (!validation) return ""; - if (validation.valid && validation.warnings.length === 0) return "text-emerald-400"; - if (validation.valid && validation.warnings.length > 0) return "text-amber-400"; - return "text-red-400"; - }, [validation]); - return (
- {/* Hero Section */}
@@ -245,18 +99,16 @@ export default function SandboxClient() {

Agent Sandbox

- Test and validate your agent.json configurations. Check syntax, required fields, and endpoint accessibility. + Validate your agent.json against required fields and get a completeness score before publishing.

- {/* Main Content */}
- {/* Editor Panel */}
-
+

Configuration Editor

- {/* Results Panel */}
-

Results

+

Validation Results

- {!validation && endpointTests.length === 0 ? ( + {!validation && !requestError ? (
- Click “Validate” to check your configuration or “Test Endpoints” to verify URLs. + Click "Validate" to check required fields, optional recommendations, and completeness score. +
+ ) : null} + + {requestError ? ( +
+ {requestError}
) : null} - {/* Validation Results */} {validation ? (
-
-
- {validation.valid && validation.warnings.length === 0 - ? "✓ Valid Configuration" - : validation.valid - ? "⚠ Valid with Warnings" - : "✗ Invalid Configuration"} +
+ {validation.valid && validation.warnings.length === 0 + ? "✓ Valid Configuration" + : validation.valid + ? "⚠ Valid with Warnings" + : "✗ Invalid Configuration"} +
+ +
+
+ Completeness Score + {validation.score}/100 +
+
+
{validation.errors.length > 0 ? (
-
Errors:
+
Errors
    - {validation.errors.map((error, i) => ( -
  • + {validation.errors.map((error) => ( +
  • {error}
  • @@ -363,10 +227,10 @@ export default function SandboxClient() { {validation.warnings.length > 0 ? (
    -
    Warnings:
    +
    Warnings
      - {validation.warnings.map((warning, i) => ( -
    • + {validation.warnings.map((warning) => ( +
    • {warning}
    • @@ -374,89 +238,15 @@ export default function SandboxClient() {
    ) : null} - - {validation.valid && validation.errors.length === 0 && validation.warnings.length === 0 ? ( -
    - All required fields are present and valid. -
    - ) : null}
- Required fields: name, version, description + Required: name, description, version, capabilities
- Recommended fields: capabilities, endpoints + Optional: identity, endpoints, protocols, trust
) : null} - - {/* Endpoint Test Results */} - {endpointTests.length > 0 ? ( -
-
-
- Endpoint Tests ({endpointTests.length}) -
- -
- {endpointTests.map((test, i) => ( -
-
-
-
- {test.url} -
- {test.reason ? ( -
{test.reason}
- ) : null} -
-
- {test.reachable ? (test.reason ? "⚠" : "✓") : "✗"} -
-
-
- ))} -
-
- -
- Note: This performs basic URL validation only. Actual endpoint availability requires - network requests. -
-
- ) : null} -
-
-
- - {/* Related Tools */} - diff --git a/src/data/agent-templates.json b/src/data/agent-templates.json new file mode 100644 index 00000000..4a39d8dc --- /dev/null +++ b/src/data/agent-templates.json @@ -0,0 +1,66 @@ +[ + { + "id": "minimal", + "name": "Minimal", + "description": "Required fields only", + "config": { + "name": "Minimal Agent", + "description": "A minimal valid agent.json configuration.", + "version": "1.0.0", + "capabilities": ["chat"] + } + }, + { + "id": "standard", + "name": "Standard", + "description": "Required fields + identity + endpoints", + "config": { + "name": "Standard Agent", + "description": "A practical configuration with identity metadata and network endpoints.", + "version": "1.1.0", + "capabilities": ["chat", "search", "tools"], + "identity": { + "handle": "@standard-agent", + "owner": "Team Reflectt", + "website": "https://foragents.dev" + }, + "endpoints": { + "chat": "https://api.example.com/chat", + "status": "https://api.example.com/status" + } + } + }, + { + "id": "full", + "name": "Full", + "description": "All major fields populated", + "config": { + "name": "Full Featured Agent", + "description": "A complete agent.json with identity, endpoints, protocols, and trust details.", + "version": "2.0.0", + "capabilities": ["chat", "search", "planning", "automation"], + "identity": { + "handle": "@full-agent", + "owner": "Team Reflectt", + "did": "did:web:foragents.dev:agents:full-agent", + "contact": "agents@foragents.dev" + }, + "endpoints": { + "chat": "https://api.example.com/chat", + "tasks": "https://api.example.com/tasks", + "health": "https://api.example.com/health" + }, + "protocols": { + "acp": "1.0", + "mcp": true, + "a2a": true, + "http": true + }, + "trust": { + "level": "verified", + "verifiedBy": ["foragents.dev"], + "lastAudit": "2026-02-09T00:00:00Z" + } + } + } +]