feat(ui): add skills system infrastructure to Lighthouse AI (#10322)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Rubén De la Torre Vico
2026-03-18 10:28:46 +01:00
committed by GitHub
parent 1da10611e7
commit 75c4f11475
10 changed files with 228 additions and 26 deletions

View File

@@ -6,6 +6,10 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added ### 🚀 Added
- Add skill system to Lighthouse AI [(#10322)](https://github.com/prowler-cloud/prowler/pull/10322)
### 🔄 Changed
- Google Workspace provider support [(#10333)](https://github.com/prowler-cloud/prowler/pull/10333) - Google Workspace provider support [(#10333)](https://github.com/prowler-cloud/prowler/pull/10333)
- Image (Container Registry) provider support in UI: badge icon, credentials form, and provider-type filtering [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167) - Image (Container Registry) provider support in UI: badge icon, credentials form, and provider-type filtering [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167)
- Organization and organizational unit row actions (Edit Name, Update Credentials, Test Connections, Delete) in providers table dropdown [(#10317)](https://github.com/prowler-cloud/prowler/pull/10317) - Organization and organizational unit row actions (Edit Name, Update Credentials, Test Connections, Delete) in providers table dropdown [(#10317)](https://github.com/prowler-cloud/prowler/pull/10317)

View File

@@ -9,6 +9,7 @@ import {
MESSAGE_ROLES, MESSAGE_ROLES,
MESSAGE_STATUS, MESSAGE_STATUS,
META_TOOLS, META_TOOLS,
SKILL_PREFIX,
} from "@/lib/lighthouse/constants"; } from "@/lib/lighthouse/constants";
import type { ChainOfThoughtData, Message } from "@/lib/lighthouse/types"; import type { ChainOfThoughtData, Message } from "@/lib/lighthouse/types";
@@ -70,17 +71,28 @@ export function getChainOfThoughtStepLabel(
return `Executing ${tool}`; return `Executing ${tool}`;
} }
if (metaTool === META_TOOLS.LOAD_SKILL && tool) {
const skillId = tool.startsWith(SKILL_PREFIX)
? tool.slice(SKILL_PREFIX.length)
: tool;
return `Loading ${skillId} skill`;
}
return tool || "Completed"; return tool || "Completed";
} }
/** /**
* Determines if a meta-tool is a wrapper tool (describe_tool or execute_tool) * Determines if a tool name is a meta-tool (describe_tool, execute_tool, or load_skill)
* *
* @param metaTool - The meta-tool name to check * @param metaTool - The meta-tool name to check
* @returns True if it's a meta-tool, false otherwise * @returns True if it's a meta-tool, false otherwise
*/ */
export function isMetaTool(metaTool: string): boolean { export function isMetaTool(metaTool: string): boolean {
return metaTool === META_TOOLS.DESCRIBE || metaTool === META_TOOLS.EXECUTE; return (
metaTool === META_TOOLS.DESCRIBE ||
metaTool === META_TOOLS.EXECUTE ||
metaTool === META_TOOLS.LOAD_SKILL
);
} }
/** /**

View File

@@ -9,6 +9,7 @@ import {
ERROR_PREFIX, ERROR_PREFIX,
LIGHTHOUSE_AGENT_TAG, LIGHTHOUSE_AGENT_TAG,
META_TOOLS, META_TOOLS,
SKILL_PREFIX,
STREAM_MESSAGE_ID, STREAM_MESSAGE_ID,
} from "@/lib/lighthouse/constants"; } from "@/lib/lighthouse/constants";
import type { ChainOfThoughtData, StreamEvent } from "@/lib/lighthouse/types"; import type { ChainOfThoughtData, StreamEvent } from "@/lib/lighthouse/types";
@@ -16,10 +17,35 @@ import type { ChainOfThoughtData, StreamEvent } from "@/lib/lighthouse/types";
// Re-export for convenience // Re-export for convenience
export { CHAIN_OF_THOUGHT_ACTIONS, ERROR_PREFIX, STREAM_MESSAGE_ID }; export { CHAIN_OF_THOUGHT_ACTIONS, ERROR_PREFIX, STREAM_MESSAGE_ID };
/**
* Safely parses the JSON string nested inside a meta-tool's input wrapper.
* In tool stream events, meta-tools receive their arguments as `{ input: "<JSON string>" }`.
* Note: In chat_model_end events, args are pre-parsed by LangChain (see handleChatModelEndEvent).
*
* @returns The parsed object, or null if parsing fails
*/
function parseMetaToolInput(
toolInput: unknown,
): Record<string, unknown> | null {
try {
if (
toolInput &&
typeof toolInput === "object" &&
"input" in toolInput &&
typeof toolInput.input === "string"
) {
return JSON.parse(toolInput.input) as Record<string, unknown>;
}
} catch {
// Failed to parse
}
return null;
}
/** /**
* Extracts the actual tool name from meta-tool input. * Extracts the actual tool name from meta-tool input.
* *
* Meta-tools (describe_tool, execute_tool) wrap actual tool calls. * Meta-tools (describe_tool, execute_tool, load_skill) wrap actual tool calls.
* This function parses the input to extract the real tool name. * This function parses the input to extract the real tool name.
* *
* @param metaToolName - The name of the meta-tool or actual tool * @param metaToolName - The name of the meta-tool or actual tool
@@ -30,26 +56,19 @@ export function extractActualToolName(
metaToolName: string, metaToolName: string,
toolInput: unknown, toolInput: unknown,
): string | null { ): string | null {
// Check if this is a meta-tool
if ( if (
metaToolName === META_TOOLS.DESCRIBE || metaToolName === META_TOOLS.DESCRIBE ||
metaToolName === META_TOOLS.EXECUTE metaToolName === META_TOOLS.EXECUTE
) { ) {
// Meta-tool: Parse the JSON string in input.input const parsed = parseMetaToolInput(toolInput);
try { return (parsed?.toolName as string) || null;
if ( }
toolInput &&
typeof toolInput === "object" && if (metaToolName === META_TOOLS.LOAD_SKILL) {
"input" in toolInput && const parsed = parseMetaToolInput(toolInput);
typeof toolInput.input === "string" return parsed?.skillId
) { ? `${SKILL_PREFIX}${parsed.skillId as string}`
const parsedInput = JSON.parse(toolInput.input); : null;
return parsedInput.toolName || null;
}
} catch {
// Failed to parse, return null
return null;
}
} }
// Actual tool execution: use the name directly // Actual tool execution: use the name directly
@@ -172,11 +191,18 @@ export function handleChatModelEndEvent(
const metaToolName = toolCall.name; const metaToolName = toolCall.name;
const toolArgs = toolCall.args; const toolArgs = toolCall.args;
// Extract actual tool name from toolArgs.toolName (camelCase) // Extract actual tool name from toolArgs
const actualToolName = let actualToolName: string | null = null;
toolArgs && typeof toolArgs === "object" && "toolName" in toolArgs if (toolArgs && typeof toolArgs === "object") {
? (toolArgs.toolName as string) if ("toolName" in toolArgs) {
: null; actualToolName = toolArgs.toolName as string;
} else if (
metaToolName === META_TOOLS.LOAD_SKILL &&
"skillId" in toolArgs
) {
actualToolName = `${SKILL_PREFIX}${toolArgs.skillId as string}`;
}
}
controller.enqueue( controller.enqueue(
createChainOfThoughtEvent({ createChainOfThoughtEvent({

View File

@@ -6,6 +6,7 @@
export const META_TOOLS = { export const META_TOOLS = {
DESCRIBE: "describe_tool", DESCRIBE: "describe_tool",
EXECUTE: "execute_tool", EXECUTE: "execute_tool",
LOAD_SKILL: "load_skill",
} as const; } as const;
export type MetaTool = (typeof META_TOOLS)[keyof typeof META_TOOLS]; export type MetaTool = (typeof META_TOOLS)[keyof typeof META_TOOLS];
@@ -68,5 +69,7 @@ export const STREAM_MESSAGE_ID = "msg-1";
export const ERROR_PREFIX = "[LIGHTHOUSE_ANALYST_ERROR]:"; export const ERROR_PREFIX = "[LIGHTHOUSE_ANALYST_ERROR]:";
export const SKILL_PREFIX = "skill:";
export const TOOLS_UNAVAILABLE_MESSAGE = export const TOOLS_UNAVAILABLE_MESSAGE =
"\nProwler tools are unavailable. You cannot access cloud accounts or security scan data. If asked about security status or scan results, inform the user that this data is currently inaccessible.\n"; "\nProwler tools are unavailable. You cannot access cloud accounts or security scan data. If asked about security status or scan results, inform the user that this data is currently inaccessible.\n";

View File

@@ -0,0 +1,8 @@
// Re-export registry functions and types
export {
getAllSkillMetadata,
getRegisteredSkillIds,
getSkillById,
registerSkill,
} from "./registry";
export type { SkillDefinition, SkillMetadata } from "./types";

View File

@@ -0,0 +1,21 @@
import "server-only";
import type { SkillDefinition, SkillMetadata } from "./types";
const skillRegistry = new Map<string, SkillDefinition>();
export function registerSkill(skill: SkillDefinition): void {
skillRegistry.set(skill.metadata.id, skill);
}
export function getAllSkillMetadata(): SkillMetadata[] {
return Array.from(skillRegistry.values()).map((skill) => skill.metadata);
}
export function getSkillById(id: string): SkillDefinition | undefined {
return skillRegistry.get(id);
}
export function getRegisteredSkillIds(): string[] {
return Array.from(skillRegistry.keys());
}

View File

@@ -0,0 +1,10 @@
export interface SkillMetadata {
id: string;
name: string;
description: string;
}
export interface SkillDefinition {
metadata: SkillMetadata;
instructions: string;
}

View File

@@ -3,6 +3,8 @@
* *
* {{TOOL_LISTING}} placeholder will be replaced with dynamically generated tool list * {{TOOL_LISTING}} placeholder will be replaced with dynamically generated tool list
*/ */
import type { SkillMetadata } from "@/lib/lighthouse/skills/types";
export const LIGHTHOUSE_SYSTEM_PROMPT_TEMPLATE = ` export const LIGHTHOUSE_SYSTEM_PROMPT_TEMPLATE = `
## Introduction ## Introduction
@@ -45,7 +47,7 @@ You have access to tools from multiple sources:
## Tool Usage ## Tool Usage
You have access to TWO meta-tools to interact with the available tools: You have access to THREE meta-tools to interact with the available tools and skills:
1. **describe_tool** - Get detailed schema for a specific tool 1. **describe_tool** - Get detailed schema for a specific tool
- Use exact tool name from the list above - Use exact tool name from the list above
@@ -59,6 +61,13 @@ You have access to TWO meta-tools to interact with the available tools:
- Example: execute_tool({ "toolName": "prowler_hub_list_providers", "toolInput": {} }) - Example: execute_tool({ "toolName": "prowler_hub_list_providers", "toolInput": {} })
- Example: execute_tool({ "toolName": "prowler_app_search_security_findings", "toolInput": { "severity": ["critical", "high"], "status": ["FAIL"] } }) - Example: execute_tool({ "toolName": "prowler_app_search_security_findings", "toolInput": { "severity": ["critical", "high"], "status": ["FAIL"] } })
3. **load_skill** - Load specialized instructions for a complex task
- Use when you identify a matching skill from the skill catalog below
- Returns detailed workflows, schema knowledge, and examples
- Example: load_skill({ "skillId": "<skill-id-from-catalog-below>" })
{{SKILL_CATALOG}}
## General Instructions ## General Instructions
- **DON'T ASSUME**. Base your answers on the system prompt or tool outputs before responding to the user. - **DON'T ASSUME**. Base your answers on the system prompt or tool outputs before responding to the user.
@@ -229,6 +238,26 @@ When providing proactive recommendations to secure users' cloud accounts, follow
- Prowler Documentation: https://docs.prowler.com/ - Prowler Documentation: https://docs.prowler.com/
`; `;
/**
* Generates the skill catalog section for the system prompt.
* Lists all registered skills with their metadata so the LLM can match user requests.
*/
export function generateSkillCatalog(skills: SkillMetadata[]): string {
if (skills.length === 0) {
return "";
}
let catalog = "## Skill Catalog\n\n";
catalog +=
"When a user request matches a skill below, use load_skill to get detailed instructions before proceeding.\n\n";
for (const skill of skills) {
catalog += `- **${skill.id}**: ${skill.name} - ${skill.description}\n`;
}
return catalog;
}
/** /**
* Generates the user-provided data section with security boundary * Generates the user-provided data section with security boundary
*/ */

View File

@@ -0,0 +1,82 @@
import "server-only";
import { tool } from "@langchain/core/tools";
import { addBreadcrumb } from "@sentry/nextjs";
import { z } from "zod";
import {
getRegisteredSkillIds,
getSkillById,
} from "@/lib/lighthouse/skills/index";
interface SkillLoadedResult {
found: true;
skillId: string;
name: string;
instructions: string;
}
interface SkillNotFoundResult {
found: false;
skillId: string;
message: string;
availableSkills: string[];
}
type LoadSkillResult = SkillLoadedResult | SkillNotFoundResult;
export const loadSkill = tool(
async ({ skillId }: { skillId: string }): Promise<LoadSkillResult> => {
addBreadcrumb({
category: "skill",
message: `load_skill called for: ${skillId}`,
level: "info",
data: { skillId },
});
const skill = getSkillById(skillId);
if (!skill) {
const availableSkills = getRegisteredSkillIds();
addBreadcrumb({
category: "skill",
message: `Skill not found: ${skillId}`,
level: "warning",
data: { skillId, availableSkills },
});
return {
found: false,
skillId,
message: `Skill '${skillId}' not found.`,
availableSkills,
};
}
return {
found: true,
skillId: skill.metadata.id,
name: skill.metadata.name,
instructions: skill.instructions,
};
},
{
name: "load_skill",
description: `Load detailed instructions for a specialized skill.
Skills provide domain-specific guidance, workflows, and schema knowledge for complex tasks.
Use this when you identify a relevant skill from the skill catalog in your system prompt.
Returns:
- Skill metadata (id, name)
- Full skill instructions with workflows and examples`,
schema: z.object({
skillId: z
.string()
.describe(
"The ID of the skill to load (from the skill catalog in your system prompt)",
),
}),
},
);

View File

@@ -12,10 +12,13 @@ import {
initializeMCPClient, initializeMCPClient,
isMCPAvailable, isMCPAvailable,
} from "@/lib/lighthouse/mcp-client"; } from "@/lib/lighthouse/mcp-client";
import { getAllSkillMetadata } from "@/lib/lighthouse/skills/index";
import { import {
generateSkillCatalog,
generateUserDataSection, generateUserDataSection,
LIGHTHOUSE_SYSTEM_PROMPT_TEMPLATE, LIGHTHOUSE_SYSTEM_PROMPT_TEMPLATE,
} from "@/lib/lighthouse/system-prompt"; } from "@/lib/lighthouse/system-prompt";
import { loadSkill } from "@/lib/lighthouse/tools/load-skill";
import { describeTool, executeTool } from "@/lib/lighthouse/tools/meta-tool"; import { describeTool, executeTool } from "@/lib/lighthouse/tools/meta-tool";
import { getModelParams } from "@/lib/lighthouse/utils"; import { getModelParams } from "@/lib/lighthouse/utils";
@@ -136,6 +139,10 @@ export async function initLighthouseWorkflow(runtimeConfig?: RuntimeConfig) {
toolListing, toolListing,
); );
// Generate and inject skill catalog
const skillCatalog = generateSkillCatalog(getAllSkillMetadata());
systemPrompt = systemPrompt.replace("{{SKILL_CATALOG}}", skillCatalog);
// Add user-provided data section if available // Add user-provided data section if available
const userDataSection = generateUserDataSection( const userDataSection = generateUserDataSection(
runtimeConfig?.businessContext, runtimeConfig?.businessContext,
@@ -177,7 +184,7 @@ export async function initLighthouseWorkflow(runtimeConfig?: RuntimeConfig) {
const agent = createAgent({ const agent = createAgent({
model: llm, model: llm,
tools: [describeTool, executeTool], tools: [describeTool, executeTool, loadSkill],
systemPrompt, systemPrompt,
}); });