diff --git a/app/Agents/Providers/DynamicProviderResolver.php b/app/Agents/Providers/DynamicProviderResolver.php index db35cf9..f744626 100644 --- a/app/Agents/Providers/DynamicProviderResolver.php +++ b/app/Agents/Providers/DynamicProviderResolver.php @@ -5,6 +5,8 @@ use App\Models\IntegrationSetting; use App\Models\User; use InvalidArgumentException; +use OpenCompany\PrismRelay\Meta\ProviderMeta; +use OpenCompany\PrismRelay\RelayManager; class DynamicProviderResolver { @@ -74,7 +76,7 @@ public function resolveFromParts(string $providerKey, string $model): array */ private function isGlmProvider(string $providerKey): bool { - return in_array($providerKey, ['glm', 'glm-coding', 'kimi', 'kimi-coding', 'minimax', 'minimax-cn']); + return (new RelayManager)->isRelayProvider($providerKey); } /** @@ -192,15 +194,8 @@ private function applyIntegrationConfig(string $providerKey): void */ private function getDefaultGlmUrl(string $providerKey): string { - return match ($providerKey) { - 'glm' => 'https://open.bigmodel.cn/api/paas/v4', - 'glm-coding' => 'https://api.z.ai/api/coding/paas/v4', - 'kimi' => 'https://api.moonshot.ai/v1', - 'kimi-coding' => 'https://api.moonshot.ai/v1', - 'minimax' => 'https://api.minimax.io/anthropic/v1', - 'minimax-cn' => 'https://api.minimaxi.com/anthropic/v1', - default => throw new InvalidArgumentException("Unknown custom provider: {$providerKey}"), - }; + return (new ProviderMeta)->url($providerKey) + ?? throw new InvalidArgumentException("Unknown custom provider: {$providerKey}"); } /** @@ -216,25 +211,7 @@ private function getDefaultModel(string $providerKey): string return array_key_first($models); } - // Hardcoded fallbacks for providers without DB-stored models - return match ($providerKey) { - 'glm' => 'glm-4-plus', - 'glm-coding' => 'glm-4.7', - 'kimi' => 'kimi-k2.5', - 'kimi-coding' => 'kimi-k2.5', - 'minimax' => 'MiniMax-M1', - 'minimax-cn' => 'MiniMax-M1', - 'codex' => 'gpt-5.3-codex', - 'anthropic' => 'claude-sonnet-4-5-20250929', - 'openai' => 'gpt-4o', - 'gemini' => 'gemini-2.0-flash', - 'groq' => 'llama-3.3-70b-versatile', - 'xai' => 'grok-2', - 'deepseek' => 'deepseek-chat', - 'mistral' => 'mistral-large-latest', - 'ollama' => 'llama3.2', - 'openrouter' => 'anthropic/claude-sonnet-4-5-20250929', - default => 'default', - }; + // Fall back to prism-relay's provider metadata registry + return (new ProviderMeta)->defaultModel($providerKey) ?? 'default'; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8c6f242..a9f8846 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -22,8 +22,6 @@ use Laravel\Ai\AiManager; use Laravel\Ai\Providers\OpenAiProvider; use Prism\Prism\PrismManager; -use Prism\Prism\Providers\Anthropic\Anthropic; -use Prism\Prism\Providers\DeepSeek\DeepSeek; class AppServiceProvider extends ServiceProvider { @@ -32,6 +30,8 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { + $this->app->singleton(ToolRegistry::class); + // Override the default config-based credential resolver with DB-backed one $this->app->singleton( \OpenCompany\IntegrationCore\Contracts\CredentialResolver::class, @@ -80,44 +80,8 @@ public function boot(): void }); } - // Register GLM (Zhipu AI) as custom Prism providers - // GLM uses OpenAI-compatible chat/completions API (same as DeepSeek) - $prismManager = $this->app->make(PrismManager::class); - $glmPrismFactory = function ($app, array $config) { - return new DeepSeek( - apiKey: $config['api_key'] ?? '', - url: $config['url'] ?? 'https://api.z.ai/api/coding/paas/v4', - ); - }; - $prismManager->extend('glm', $glmPrismFactory); - $prismManager->extend('glm-coding', $glmPrismFactory); - - // Register Kimi (Moonshot AI) as custom Prism providers (same pattern as GLM) - $kimiPrismFactory = function ($app, array $config) { - return new DeepSeek( - apiKey: $config['api_key'] ?? '', - url: $config['url'] ?? 'https://api.moonshot.ai/v1', - ); - }; - $prismManager->extend('kimi', $kimiPrismFactory); - $prismManager->extend('kimi-coding', $kimiPrismFactory); - - // Register MiniMax Coding Plan as custom Prism providers (Anthropic-compatible API) - $minimaxPrismFactory = function ($app, array $config) { - return new Anthropic( - apiKey: $config['api_key'] ?? '', - apiVersion: '2023-06-01', - url: $config['url'] ?? 'https://api.minimax.io/anthropic/v1', - ); - }; - $prismManager->extend('minimax', $minimaxPrismFactory); - $prismManager->extend('minimax-cn', function ($app, array $config) { - return new Anthropic( - apiKey: $config['api_key'] ?? '', - apiVersion: '2023-06-01', - url: $config['url'] ?? 'https://api.minimaxi.com/anthropic/v1', - ); - }); + // Custom Prism providers (GLM, Kimi, MiniMax) are registered by + // PrismRelayServiceProvider via afterResolving(PrismManager::class). // Register 'glm' and 'glm-coding' as custom AI SDK drivers. // These use GlmPrismGateway which routes to our custom 'glm' Prism provider diff --git a/app/Services/LuaApiDocGenerator.php b/app/Services/LuaApiDocGenerator.php index 4387b7b..f9bc19c 100644 --- a/app/Services/LuaApiDocGenerator.php +++ b/app/Services/LuaApiDocGenerator.php @@ -4,6 +4,8 @@ use App\Agents\Tools\ToolRegistry; use App\Models\User; +use OpenCompany\IntegrationCore\Lua\LuaCatalogBuilder; +use OpenCompany\IntegrationCore\Lua\LuaDocRenderer; use OpenCompany\IntegrationCore\Support\ToolProviderRegistry; class LuaApiDocGenerator @@ -16,576 +18,90 @@ class LuaApiDocGenerator public function __construct( private ToolRegistry $registry, private ToolProviderRegistry $providerRegistry, + private LuaCatalogBuilder $catalogBuilder, + private LuaDocRenderer $docRenderer, ) {} - /** - * Generate a full namespace index listing all available functions. - */ public function generateNamespaceIndex(User $agent, ?string $filterNamespace = null): string { - $namespaces = $this->buildNamespaces($agent); - - if ($filterNamespace !== null) { - $namespaces = array_filter( - $namespaces, - fn (mixed $_value, string $key) => $key === $filterNamespace || str_starts_with($key, $filterNamespace . '.'), - ARRAY_FILTER_USE_BOTH, - ); - - if (empty($namespaces)) { - return "Namespace '{$filterNamespace}' not found. Available: " . implode(', ', array_keys($this->buildNamespaces($agent))); - } - } - - $lines = ['Available Lua API namespaces:', '']; - - foreach ($namespaces as $nsName => $ns) { - $lines[] = "**app.{$nsName}** — {$ns['description']}"; - - foreach ($ns['functions'] as $fn) { - $sig = $this->buildSignature($fn); - $lines[] = " app.{$nsName}.{$sig}"; - } - - $lines[] = ''; - } - - // Append supplementary docs - $staticPages = $this->getStaticPages(); - if (!empty($staticPages)) { - $lines[] = 'Supplementary docs: ' . implode(', ', array_keys($staticPages)); - $lines[] = 'Use lua_read_doc to read any page or namespace in detail.'; - } - - return implode("\n", $lines); + return $this->docRenderer->generateNamespaceIndex( + $this->buildNamespaces($agent), + $this->getStaticPageContents(), + $filterNamespace, + ); } - /** - * Generate detailed docs for a specific namespace. - */ public function generateNamespaceDocs(string $namespace, User $agent): string { - $namespaces = $this->buildNamespaces($agent); - - if (!isset($namespaces[$namespace])) { - return "Namespace '{$namespace}' not found. Available: " . implode(', ', array_keys($namespaces)); - } - - $ns = $namespaces[$namespace]; - $lines = ["# app.{$namespace} — {$ns['description']}", '']; - - foreach ($ns['functions'] as $fn) { - $sig = $this->buildSignature($fn); - $lines[] = "## app.{$namespace}.{$sig}"; - $lines[] = ''; - - if (!empty($fn['description'])) { - $lines[] = $fn['description']; - $lines[] = ''; - } - - $lines[] = $this->formatParameterTable($fn['parameters']); - $lines[] = ''; - } - - // Append integration-specific supplementary docs if available - $supplementary = $this->getProviderLuaDocs($namespace); - if ($supplementary !== null) { - $lines[] = '---'; - $lines[] = ''; - $lines[] = $supplementary; - } - - return implode("\n", $lines); + return $this->docRenderer->generateNamespaceDocs( + $namespace, + $this->buildNamespaces($agent), + fn (string $ns) => $this->getProviderLuaDocs($ns), + ); } - /** - * Generate docs for a single function within a namespace. - */ public function generateFunctionDocs(string $namespace, string $function, User $agent): string { - $namespaces = $this->buildNamespaces($agent); - - if (!isset($namespaces[$namespace])) { - return "Namespace '{$namespace}' not found."; - } - - $fn = collect($namespaces[$namespace]['functions'])->firstWhere('name', $function); - - if (!$fn) { - $available = implode(', ', array_column($namespaces[$namespace]['functions'], 'name')); - - return "Function '{$function}' not found in app.{$namespace}. Available: {$available}"; - } - - $sig = $this->buildSignature($fn); - $lines = [ - "# app.{$namespace}.{$sig}", - '', - ]; - - if (!empty($fn['description'])) { - $lines[] = $fn['description']; - $lines[] = ''; - } - - if (!empty($fn['fullDescription']) && $fn['fullDescription'] !== ($fn['description'] ?? '')) { - $lines[] = $fn['fullDescription']; - $lines[] = ''; - } - - $lines[] = $this->formatParameterTable($fn['parameters']); - - if (!empty($fn['sourceToolSlug'])) { - $lines[] = ''; - $lines[] = "*(Maps to tool: `{$fn['sourceToolSlug']}`)*"; - } - - return implode("\n", $lines); + return $this->docRenderer->generateFunctionDocs( + $namespace, + $function, + $this->buildNamespaces($agent), + ); } - /** - * Search across all auto-generated and static docs. - */ public function search(string $query, User $agent, int $limit = 10): string { - $queryLower = strtolower($query); - $results = []; - - // Search auto-generated namespaces - $namespaces = $this->buildNamespaces($agent); - - foreach ($namespaces as $nsName => $ns) { - foreach ($ns['functions'] as $fn) { - $score = $this->scoreMatch($fn, $nsName, $queryLower); - - if ($score > 0) { - $sig = $this->buildSignature($fn); - $results[] = [ - 'score' => $score, - 'text' => "**app.{$nsName}.{$sig}** — {$fn['description']}", - ]; - } - } - } - - // Search static docs - $staticPages = $this->getStaticPages(); - foreach ($staticPages as $slug => $path) { - $content = file_get_contents($path); - if ($content === false) { - continue; - } - - if (stripos($content, $query) !== false) { - // Extract matching context - $contextSnippet = $this->extractSearchContext($content, $query); - $results[] = [ - 'score' => 1, - 'text' => "**[{$slug}]** (supplementary doc)\n{$contextSnippet}", - ]; - } - } - - // Sort by score desc - usort($results, fn ($a, $b) => $b['score'] <=> $a['score']); - $results = array_slice($results, 0, $limit); - - if (empty($results)) { - return "No results found for '{$query}'."; - } - - $lines = ["Found " . count($results) . " result(s) for '{$query}':", '']; - foreach ($results as $r) { - $lines[] = $r['text']; - $lines[] = ''; - } - - return implode("\n", $lines); + return $this->docRenderer->search( + $query, + $this->buildNamespaces($agent), + $this->getStaticPageContents(), + $limit, + ); } /** - * Build the namespace structure from the tool catalog. - * * @return array */ private function buildNamespaces(User $agent): array { - // Cache per agent within the same request if ($this->cachedNamespaces !== null && $this->cachedAgent?->id === $agent->id) { return $this->cachedNamespaces; } - $catalog = $this->registry->getToolCatalog($agent); - $namespaces = []; - - foreach ($catalog as $app) { - $appName = $app['name']; - - // Skip meta/system tools that aren't meaningful as Lua API - if (in_array($appName, ['tasks', 'system', 'lua'])) { - continue; - } - - // Determine namespace - if ($app['isIntegration'] ?? false) { - $nsName = "integrations.{$appName}"; - } else { - $nsName = $appName; - } - - $functions = []; - - foreach ($app['tools'] as $tool) { - $slug = $tool['slug']; - - // MCP tools: extract server name from slug pattern mcp_{server}__{tool} - if (str_starts_with($slug, 'mcp_')) { - $nsName = $this->mcpNamespace($slug); - $fnName = $this->mcpFunctionName($slug); - $functions[] = $this->buildFunction($fnName, $tool, $slug); - - continue; - } - - $fnName = $this->deriveFunctionName($tool['name'], $appName); - $functions[] = $this->buildFunction($fnName, $tool, $slug); - } - - if (empty($functions)) { - continue; - } - - // MCP tools might create multiple namespaces, merge them - if (!isset($namespaces[$nsName])) { - $namespaces[$nsName] = [ - 'description' => $app['description'], - 'functions' => [], - ]; - } - - $namespaces[$nsName]['functions'] = array_merge( - $namespaces[$nsName]['functions'], - $functions, - ); - } - - // Sort namespaces: internal first, then integrations, then mcp - uksort($namespaces, function ($a, $b) { - $aWeight = str_starts_with($a, 'mcp.') ? 2 : (str_starts_with($a, 'integrations.') ? 1 : 0); - $bWeight = str_starts_with($b, 'mcp.') ? 2 : (str_starts_with($b, 'integrations.') ? 1 : 0); - - return $aWeight <=> $bWeight ?: strcmp($a, $b); - }); - - $this->cachedNamespaces = $namespaces; + $this->cachedNamespaces = $this->catalogBuilder->buildNamespaces( + $this->registry->getToolCatalog($agent), + ['tasks', 'system', 'lua'], + ); $this->cachedAgent = $agent; - return $namespaces; - } - - /** - * Build a function entry from a tool without action decomposition. - */ - private function buildFunction(string $name, array $tool, string $slug): array - { - return [ - 'name' => $name, - 'description' => $tool['fullDescription'] ?? $tool['description'], - 'fullDescription' => $tool['fullDescription'] ?? '', - 'parameters' => $tool['parameters'] ?? [], - 'sourceToolSlug' => $slug, - ]; - } - - /** - * Derive a Lua function name from a tool's human-readable name and its app name. - * Strips words that share a root with the app name, plus prepositions. - * - * Examples: - * ("Query Documents", "docs") → "query" - * ("Comment on Document", "docs") → "comment" - * ("Manage Table Rows", "tables") → "manage_rows" - * ("Create Task", "clickup") → "create_task" - * ("Send Channel Message", "chat") → "send_channel_message" - */ - private function deriveFunctionName(string $toolName, string $appName): string - { - // Lowercase and replace any non-alphanumeric chars with underscores to ensure valid Lua identifiers - $snake = strtolower(trim($toolName)); - $snake = preg_replace('/[^a-z0-9]+/', '_', $snake); - $snake = trim($snake, '_'); - - $words = explode('_', $snake); - $appBase = rtrim(strtolower($appName), 's'); - - $filtered = array_values(array_filter($words, function ($word) use ($appBase) { - if (in_array($word, ['on', 'of', 'for', 'in', 'to', 'the', 'a', 'an'])) { - return false; - } - - $wordBase = rtrim($word, 's'); - - return !str_contains($wordBase, $appBase) && !str_contains($appBase, $wordBase); - })); - - return implode('_', $filtered) ?: $snake; - } - - /** - * Extract MCP namespace from tool slug: mcp_{server}__{tool} → mcp.{server} - */ - private function mcpNamespace(string $slug): string - { - // Pattern: mcp_{server_slug}__{tool_name} - if (preg_match('/^mcp_(.+?)__/', $slug, $matches)) { - return 'mcp.' . $matches[1]; - } - - return 'mcp'; - } - - /** - * Extract MCP function name from tool slug: mcp_{server}__{tool} → {tool} - */ - private function mcpFunctionName(string $slug): string - { - if (preg_match('/^mcp_.+?__(.+)$/', $slug, $matches)) { - return preg_replace('/[^a-z0-9]+/', '_', strtolower($matches[1])); - } - - return preg_replace('/[^a-z0-9]+/', '_', strtolower($slug)); - } - - /** - * Build a function signature string like: send(channelId, content) - */ - private function buildSignature(array $fn): string - { - $params = []; - foreach ($fn['parameters'] as $param) { - $name = $this->toSnakeCase($param['name']); - $required = $param['required'] ?? false; - $params[] = $required ? $name : $name . '?'; - } - - return $fn['name'] . '({' . implode(', ', $params) . '})'; - } - - /** - * Format parameters as a markdown table. - */ - private function formatParameterTable(array $parameters): string - { - if (empty($parameters)) { - return '*No parameters.*'; - } - - $lines = [ - '| Parameter | Type | Required | Description |', - '|-----------|------|----------|-------------|', - ]; - - foreach ($parameters as $param) { - $name = $this->toSnakeCase($param['name']); - $type = $param['type'] ?? 'string'; - if (is_array($type)) { - $type = implode(' | ', $type); - } - $required = ($param['required'] ?? false) ? 'yes' : 'no'; - $desc = $param['description'] ?? ''; - - // Add enum values if present - if (!empty($param['enum'])) { - $enumStr = implode(', ', array_map(fn ($v) => "`{$v}`", $param['enum'])); - $desc .= ($desc ? ' ' : '') . "Values: {$enumStr}"; - } - - $lines[] = "| {$name} | {$type} | {$required} | {$desc} |"; - } - - return implode("\n", $lines); - } - - /** - * Convert camelCase to snake_case for Lua-facing parameter names. - */ - private function toSnakeCase(string $name): string - { - return strtolower(preg_replace('/[A-Z]/', '_$0', $name)); - } - - /** - * Score how well a function matches a search query. - */ - private function scoreMatch(array $fn, string $nsName, string $queryLower): int - { - $score = 0; - - // Score against each query word individually - $words = array_filter(explode(' ', $queryLower)); - - foreach ($words as $word) { - // Exact function name match - if (strtolower($fn['name']) === $word) { - $score += 10; - } elseif (str_contains(strtolower($fn['name']), $word)) { - $score += 5; - } - - // Namespace match - if (str_contains(strtolower($nsName), $word)) { - $score += 3; - } - - // Description match - if (str_contains(strtolower($fn['description'] ?? ''), $word)) { - $score += 2; - } - - // Parameter name/description match - foreach ($fn['parameters'] as $param) { - if (str_contains(strtolower($param['name']), $word)) { - $score += 1; - } - if (str_contains(strtolower($param['description'] ?? ''), $word)) { - $score += 1; - } - } - } - - // Bonus: full query appears in description - if (str_contains(strtolower($fn['description'] ?? ''), $queryLower)) { - $score += 5; - } - - return $score; - } - - /** - * Extract context around a search match in static docs. - */ - private function extractSearchContext(string $content, string $query): string - { - $lines = explode("\n", $content); - $snippets = []; - - foreach ($lines as $i => $line) { - if (stripos($line, $query) === false) { - continue; - } - - // Get ±2 lines of context - $start = max(0, $i - 2); - $end = min(count($lines) - 1, $i + 2); - $context = array_slice($lines, $start, $end - $start + 1); - $snippets[] = implode("\n", $context); - - if (count($snippets) >= 2) { - break; - } - } - - return implode("\n...\n", $snippets); + return $this->cachedNamespaces; } /** - * Get available static documentation pages. - * - * @return array slug => file path + * @return array path => toolSlug */ - private function getStaticPages(): array + public function buildFunctionMap(User $agent): array { - $dir = resource_path('lua-docs'); - - if (!is_dir($dir)) { - return []; - } - - $pages = []; - foreach (glob($dir . '/*.md') as $file) { - $slug = pathinfo($file, PATHINFO_FILENAME); - // Normalize _overview → overview - $slug = ltrim($slug, '_'); - $pages[$slug] = $file; - } - - return $pages; + return $this->catalogBuilder->buildFunctionMap($this->buildNamespaces($agent)); } /** - * Read a static documentation page. + * @return array> path => [paramName1, paramName2, ...] */ - public function readStaticPage(string $slug): ?string + public function buildParameterMap(User $agent): array { - $pages = $this->getStaticPages(); - - if (isset($pages[$slug])) { - $content = file_get_contents($pages[$slug]); - - return $content !== false ? $content : null; - } - - return null; + return $this->catalogBuilder->buildParameterMap($this->buildNamespaces($agent)); } /** - * Get all available page names (namespaces + static pages). - * * @return list */ public function getAvailablePages(User $agent): array { - $pages = array_keys($this->getStaticPages()); - $pages = array_merge($pages, array_keys($this->buildNamespaces($agent))); - - sort($pages); - - return $pages; - } - - /** - * Build a flat map of function paths to tool slugs. - * Used by LuaBridge to route Lua app.* calls to PHP tools. - * - * @return array path => toolSlug - */ - public function buildFunctionMap(User $agent): array - { - $namespaces = $this->buildNamespaces($agent); - $map = []; - - foreach ($namespaces as $nsName => $ns) { - foreach ($ns['functions'] as $fn) { - $map[$nsName . '.' . $fn['name']] = $fn['sourceToolSlug']; - } - } - - return $map; - } - - /** - * Build a map of function paths to their ordered parameter names. - * Used by LuaBridge to map positional arguments to named parameters. - * - * @return array> path => [paramName1, paramName2, ...] - */ - public function buildParameterMap(User $agent): array - { - $namespaces = $this->buildNamespaces($agent); - $map = []; - - foreach ($namespaces as $nsName => $ns) { - foreach ($ns['functions'] as $fn) { - $paramNames = array_map( - fn ($p) => $p['name'], - $fn['parameters'] - ); - $map[$nsName . '.' . $fn['name']] = $paramNames; - } - } - - return $map; + return $this->docRenderer->getAvailablePages( + $this->buildNamespaces($agent), + $this->getStaticPageContents(), + ); } /** @@ -594,28 +110,26 @@ public function buildParameterMap(User $agent): array */ private function getProviderLuaDocs(string $namespace): ?string { - // Extract app name: "integrations.clickup" → "clickup", "chat" → "chat" $appName = str_starts_with($namespace, 'integrations.') ? substr($namespace, strlen('integrations.')) : $namespace; $provider = $this->providerRegistry->get($appName); + if ($provider === null) { + return null; + } - if ($provider !== null) { - $path = $provider->luaDocsPath(); - if ($path !== null && is_file($path)) { - $content = file_get_contents($path); - - return $content !== false ? $content : null; - } + $path = $provider->luaDocsPath(); + if ($path === null || ! is_file($path)) { + return null; } - return null; + $content = file_get_contents($path); + + return $content !== false ? $content : null; } /** - * Get the built namespace structure for the tool catalog UI. - * * @return array */ public function getNamespacesForCatalog(User $agent): array @@ -623,17 +137,12 @@ public function getNamespacesForCatalog(User $agent): array return $this->buildNamespaces($agent); } - /** - * Get supplementary Lua docs for a namespace (public wrapper). - */ public function getSupplementaryDocs(string $namespace): ?string { return $this->getProviderLuaDocs($namespace); } /** - * Get all static documentation pages with their content. - * * @return list */ public function getStaticDocsForCatalog(): array @@ -647,10 +156,9 @@ public function getStaticDocsForCatalog(): array continue; } - // Extract title from first markdown heading $title = ucfirst($slug); - if (preg_match('/^#\s+(.+)$/m', $content, $m)) { - $title = $m[1]; + if (preg_match('/^#\s+(.+)$/m', $content, $matches) === 1) { + $title = $matches[1]; } $result[] = [ @@ -663,42 +171,66 @@ public function getStaticDocsForCatalog(): array return $result; } + public function getNamespaceSummary(User $agent): string + { + return $this->docRenderer->getNamespaceSummary($this->buildNamespaces($agent)); + } + /** - * Get a compact namespace summary for the system prompt, grouped by tier. - * Shows Internal / Integrations / MCP categories with app.* prefixed names. + * @return array slug => file path */ - public function getNamespaceSummary(User $agent): string + private function getStaticPages(): array { - $namespaces = $this->buildNamespaces($agent); - - $internal = []; - $integrations = []; - $mcp = []; - - foreach (array_keys($namespaces) as $ns) { - if (str_starts_with($ns, 'mcp.')) { - $mcp[] = "app.{$ns}"; - } elseif (str_starts_with($ns, 'integrations.')) { - $integrations[] = "app.{$ns}"; - } else { - $internal[] = "app.{$ns}"; - } - } + $dir = resource_path('lua-docs'); - $lines = []; + if (! is_dir($dir)) { + return []; + } - if (!empty($internal)) { - $lines[] = ' Internal: ' . implode(', ', $internal); + $pages = []; + foreach (glob($dir . '/*.md') ?: [] as $file) { + $slug = pathinfo($file, PATHINFO_FILENAME); + $pages[ltrim($slug, '_')] = $file; } - if (!empty($integrations)) { - $lines[] = ' Integrations: ' . implode(', ', $integrations); + + return $pages; + } + + /** + * @return array slug => content + */ + private function getStaticPageContents(): array + { + $contents = []; + + foreach ($this->getStaticPages() as $slug => $path) { + $content = file_get_contents($path); + if ($content !== false) { + $contents[$slug] = $content; + } } - if (!empty($mcp)) { - $lines[] = ' MCP: ' . implode(', ', $mcp); + + return $contents; + } + + public function readStaticPage(string $slug): ?string + { + $pages = $this->getStaticPages(); + + if (! isset($pages[$slug])) { + return null; } - $lines[] = ' Use lua_read_doc(page) for function signatures and parameters.'; + $content = file_get_contents($pages[$slug]); + + return $content !== false ? $content : null; + } - return implode("\n", $lines); + /** + * Kept for backward compatibility with tests and any internal reflection. + */ + private function deriveFunctionName(string $toolName, string $appName): string + { + return $this->catalogBuilder->deriveFunctionName($toolName, $appName); } } diff --git a/app/Services/LuaBridge.php b/app/Services/LuaBridge.php index 04bece3..f982786 100644 --- a/app/Services/LuaBridge.php +++ b/app/Services/LuaBridge.php @@ -4,194 +4,32 @@ use App\Agents\Tools\ToolRegistry; use App\Models\User; -use Illuminate\Support\Facades\Log; -use Laravel\Ai\Tools\Request; -use OpenCompany\IntegrationCore\Contracts\Tool as IntegrationTool; +use OpenCompany\IntegrationCore\Lua\LuaBridge as SharedLuaBridge; class LuaBridge { - /** @var array path => toolSlug */ - private array $functionMap; - - /** @var array> path => [paramName1, paramName2, ...] */ - private array $parameterMap; - - /** @var list */ - private array $callLog = []; + private SharedLuaBridge $bridge; public function __construct( - private User $agent, - private ToolRegistry $registry, + User $agent, + ToolRegistry $registry, LuaApiDocGenerator $docGenerator, ) { - $this->functionMap = $docGenerator->buildFunctionMap($agent); - $this->parameterMap = $docGenerator->buildParameterMap($agent); + $this->bridge = new SharedLuaBridge( + $docGenerator->buildFunctionMap($agent), + $docGenerator->buildParameterMap($agent), + new OpenCompanyLuaToolInvoker($agent, $registry), + ); } - /** - * Handle a Lua app.* function call by routing it to the corresponding PHP tool. - * - * Called from Lua via: app.chat.send_channel_message({channel_id = "abc", content = "hi"}) - * Which routes through metatables to: __app.call("chat.send_channel_message", {channel_id = "abc", ...}) - * Snake_case keys are auto-converted to camelCase for the tool. - */ - public function call(string $path, mixed ...$args): string|array + public function call(string $path, mixed ...$args): mixed { - if (!isset($this->functionMap[$path])) { - $msg = "Unknown function: app.{$path}"; - - // Suggest available functions from the same namespace - $parts = explode('.', $path); - if (count($parts) > 1) { - $nsPrefix = implode('.', array_slice($parts, 0, -1)); - $available = []; - foreach ($this->functionMap as $fnPath => $_) { - if (str_starts_with($fnPath, $nsPrefix . '.')) { - $fnParts = explode('.', $fnPath); - $available[] = end($fnParts); - } - } - if (!empty($available)) { - $msg .= '. Did you mean: ' . implode(', ', $available); - } - } - - $this->callLog[] = ['path' => $path, 'durationMs' => 0, 'status' => 'error', 'error' => $msg, 'group' => self::extractGroup($path)]; - throw new \RuntimeException($msg); - } - - $toolSlug = $this->functionMap[$path]; - $toolMeta = $this->registry->getToolMetaBySlug($toolSlug); - $group = self::extractGroup($path); - - $tool = $this->registry->instantiateToolBySlug($toolSlug, $this->agent); - - if ($tool === null) { - $this->callLog[] = ['path' => $path, 'durationMs' => 0, 'status' => 'error', 'error' => "Tool not available: {$toolSlug}", 'icon' => $toolMeta['icon'], 'name' => $toolMeta['name'], 'group' => $group]; - throw new \RuntimeException("Tool not available: {$toolSlug}"); - } - - // Build arguments from the first Lua arg (a table → PHP associative array) - $params = []; - if (!empty($args) && is_array($args[0])) { - $params = $args[0]; - } elseif (!empty($args) && !is_array($args[0])) { - // Single non-table arg — try to map to first required parameter - $params = $this->mapPositionalArgs($path, $args); - } - - $start = microtime(true); - - try { - if ($tool instanceof IntegrationTool) { - // New-style tool: parameters are snake_case, pass directly - $toolResult = $tool->execute($params); - - if (! $toolResult->succeeded()) { - throw new \RuntimeException($toolResult->error); - } - - $result = $toolResult->data; - } else { - // Legacy Laravel\Ai tool: convert snake_case → camelCase, wrap in Request - $request = new Request($this->snakeToCamel($params)); - $rawResult = $tool->handle($request); - - // Auto-decode JSON responses → PHP arrays (sandbox converts to Lua tables) - if (is_string($rawResult)) { - $trimmed = ltrim($rawResult); - if (($trimmed[0] ?? '') === '{' || ($trimmed[0] ?? '') === '[') { - $decoded = json_decode($rawResult, true); - if ($decoded !== null) { - $result = $decoded; - } else { - $result = $rawResult; - } - } else { - $result = $rawResult; - } - } else { - $result = $rawResult; - } - } - - $this->callLog[] = ['path' => $path, 'durationMs' => round((microtime(true) - $start) * 1000, 1), 'status' => 'ok', 'icon' => $toolMeta['icon'], 'name' => $toolMeta['name'], 'group' => $group]; - - return $result; - } catch (\Throwable $e) { - $this->callLog[] = ['path' => $path, 'durationMs' => round((microtime(true) - $start) * 1000, 1), 'status' => 'error', 'error' => $e->getMessage(), 'icon' => $toolMeta['icon'], 'name' => $toolMeta['name'], 'group' => $group]; - throw $e; - } + return $this->bridge->call($path, ...$args); } - /** @return list */ + /** @return list */ public function getCallLog(): array { - return $this->callLog; - } - - /** - * Convert snake_case array keys to camelCase. - * Lua convention is snake_case, tool schemas use camelCase. - * - * @return array - */ - private function snakeToCamel(array $params): array - { - $converted = []; - foreach ($params as $key => $value) { - $camelKey = lcfirst(str_replace('_', '', ucwords((string) $key, '_'))); - $converted[$camelKey] = $value; - } - - return $converted; - } - - /** - * Extract the logical group from a bridge call path. - * - * For integration tools (paths like "integrations.clickup.create_task"), - * returns the actual app name ("clickup") instead of "integrations". - */ - private static function extractGroup(string $path): string - { - $parts = explode('.', $path); - - if ($parts[0] === 'integrations' && isset($parts[1])) { - return $parts[1]; - } - - return $parts[0] ?? ''; - } - - /** - * Map positional arguments to parameter names based on the tool's schema. - * - * Allows Lua calls like app.chat.send_channel_message(channel_id, content) - * instead of requiring app.chat.send_channel_message({channel_id = "...", content = "..."}). - * - * @return array - */ - private function mapPositionalArgs(string $path, array $args): array - { - $paramNames = $this->parameterMap[$path] ?? []; - - if (empty($paramNames)) { - Log::warning('LuaBridge: positional args passed but no parameter map for function', [ - 'path' => $path, - 'arg_count' => count($args), - ]); - - return []; - } - - $mapped = []; - foreach ($args as $i => $value) { - if (isset($paramNames[$i])) { - $mapped[$paramNames[$i]] = $value; - } - } - - return $mapped; + return $this->bridge->getCallLog(); } } diff --git a/app/Services/OpenCompanyLuaToolInvoker.php b/app/Services/OpenCompanyLuaToolInvoker.php new file mode 100644 index 0000000..17b9723 --- /dev/null +++ b/app/Services/OpenCompanyLuaToolInvoker.php @@ -0,0 +1,73 @@ +registry->instantiateToolBySlug($toolSlug, $this->agent); + + if ($tool === null) { + throw new \RuntimeException("Tool not available: {$toolSlug}"); + } + + if ($tool instanceof IntegrationTool) { + $toolResult = $tool->execute($args); + + if (! $toolResult->succeeded()) { + throw new \RuntimeException($toolResult->error ?? "Tool failed: {$toolSlug}"); + } + + return $toolResult->data; + } + + $request = new Request($this->snakeToCamel($args)); + $rawResult = $tool->handle($request); + + if (! is_string($rawResult)) { + return $rawResult; + } + + $trimmed = ltrim($rawResult); + if (($trimmed[0] ?? '') !== '{' && ($trimmed[0] ?? '') !== '[') { + return $rawResult; + } + + $decoded = json_decode($rawResult, true); + + return $decoded ?? $rawResult; + } + + public function getToolMeta(string $toolSlug): array + { + return $this->registry->getToolMetaBySlug($toolSlug); + } + + /** + * @param array $params + * @return array + */ + private function snakeToCamel(array $params): array + { + $converted = []; + + foreach ($params as $key => $value) { + $camelKey = lcfirst(str_replace('_', '', ucwords((string) $key, '_'))); + $converted[$camelKey] = $value; + } + + return $converted; + } +} diff --git a/composer.json b/composer.json index fcc656c..fe50c04 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,10 @@ "type": "path", "url": "tmp/prism-codex" }, + { + "type": "path", + "url": "tmp/prism-relay" + }, { "type": "vcs", "url": "https://github.com/OpenCompanyApp/chatogrator.git" @@ -47,6 +51,7 @@ "laravel/tinker": "^2.10.1", "opencompany/chatogrator": "^1.1", "opencompany/prism-codex": "@dev", + "opencompanyapp/prism-relay": "@dev", "opencompanyapp/integration-celestial": "@dev", "opencompanyapp/integration-clickup": "@dev", "opencompanyapp/integration-coingecko": "@dev", diff --git a/composer.lock b/composer.lock index 864794a..b57ec05 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5b4af7a8235e75a625b834bb62cf4665", + "content-hash": "f29a59c5bc95a3bf57a5724035d3ab86", "packages": [ { "name": "brick/math", @@ -4176,6 +4176,40 @@ "relative": true } }, + { + "name": "opencompanyapp/prism-relay", + "version": "dev-main", + "dist": { + "type": "path", + "url": "tmp/prism-relay", + "reference": "b0ee49538c5d9ce13e9d6c55d0161a6e9c881ad3" + }, + "require": { + "php": "^8.2", + "prism-php/prism": "^0.99 || ^1.0", + "psr/log": "^3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "OpenCompany\\PrismRelay\\PrismRelayServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "OpenCompany\\PrismRelay\\": "src/" + } + }, + "license": [ + "MIT" + ], + "description": "Provider normalizer and registry for Prism PHP — custom providers, error categorization, tool call sanitization, caching strategy", + "transport-options": { + "relative": true + } + }, { "name": "paragonie/sodium_compat", "version": "v2.5.0", @@ -11463,7 +11497,8 @@ "opencompanyapp/integration-trustmrr": 20, "opencompanyapp/integration-typst": 20, "opencompanyapp/integration-vegalite": 20, - "opencompanyapp/integration-worldbank": 20 + "opencompanyapp/integration-worldbank": 20, + "opencompanyapp/prism-relay": 20 }, "prefer-stable": true, "prefer-lowest": false, diff --git a/config/integrations.php b/config/integrations.php index 3264245..2cadfcb 100644 --- a/config/integrations.php +++ b/config/integrations.php @@ -114,7 +114,7 @@ 'name' => 'MiniMax Coding Plan', 'description' => 'MiniMax coding models via Anthropic-compatible API', 'icon' => 'ph:cube', - 'default_url' => 'https://api.minimax.io/anthropic/v1', + 'default_url' => \OpenCompany\PrismRelay\Providers\MiniMax::URL, 'api_format' => 'anthropic', 'api_key_url' => 'https://platform.minimax.io/docs/coding-plan/intro', ], @@ -124,7 +124,7 @@ 'name' => 'MiniMax Coding Plan (CN)', 'description' => 'MiniMax coding models — China region endpoint', 'icon' => 'ph:cube', - 'default_url' => 'https://api.minimaxi.com/anthropic/v1', + 'default_url' => \OpenCompany\PrismRelay\Providers\MiniMaxCn::URL, 'api_format' => 'anthropic', 'api_key_url' => 'https://platform.minimaxi.com/docs/coding-plan/intro', ], @@ -134,7 +134,7 @@ 'name' => 'Kimi (Moonshot AI)', 'description' => 'Kimi K2 models — large context coding and reasoning', 'icon' => 'ph:moon-stars', - 'default_url' => 'https://api.moonshot.ai/v1', + 'default_url' => \OpenCompany\PrismRelay\Providers\Kimi::URL, 'api_format' => 'openai_compat', 'api_key_url' => 'https://platform.moonshot.ai/console', ], @@ -144,7 +144,7 @@ 'name' => 'Kimi Coding Plan', 'description' => 'Coding-focused Kimi models via Moonshot Coding Plan', 'icon' => 'ph:code', - 'default_url' => 'https://api.moonshot.ai/v1', + 'default_url' => \OpenCompany\PrismRelay\Providers\KimiCoding::URL, 'api_format' => 'openai_compat', 'api_key_url' => 'https://platform.moonshot.ai/console', ], @@ -154,7 +154,7 @@ 'name' => 'GLM (Zhipu AI)', 'description' => 'General-purpose Chinese LLM', 'icon' => 'ph:brain', - 'default_url' => 'https://open.bigmodel.cn/api/paas/v4', + 'default_url' => \OpenCompany\PrismRelay\Providers\Glm::URL, 'api_format' => 'openai_compat', 'api_key_url' => 'https://open.bigmodel.cn/', ], @@ -164,7 +164,7 @@ 'name' => 'GLM Coding Plan', 'description' => 'Specialized coding LLM via Zhipu Coding Plan', 'icon' => 'ph:code', - 'default_url' => 'https://api.z.ai/api/coding/paas/v4', + 'default_url' => \OpenCompany\PrismRelay\Providers\GlmCoding::URL, 'api_format' => 'openai_compat', 'api_key_url' => 'https://api.z.ai/', ],