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
- 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)
- 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)

View File

@@ -9,6 +9,7 @@ import {
MESSAGE_ROLES,
MESSAGE_STATUS,
META_TOOLS,
SKILL_PREFIX,
} from "@/lib/lighthouse/constants";
import type { ChainOfThoughtData, Message } from "@/lib/lighthouse/types";
@@ -70,17 +71,28 @@ export function getChainOfThoughtStepLabel(
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";
}
/**
* 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
* @returns True if it's a meta-tool, false otherwise
*/
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,
LIGHTHOUSE_AGENT_TAG,
META_TOOLS,
SKILL_PREFIX,
STREAM_MESSAGE_ID,
} from "@/lib/lighthouse/constants";
import type { ChainOfThoughtData, StreamEvent } from "@/lib/lighthouse/types";
@@ -16,10 +17,35 @@ import type { ChainOfThoughtData, StreamEvent } from "@/lib/lighthouse/types";
// Re-export for convenience
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.
*
* 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.
*
* @param metaToolName - The name of the meta-tool or actual tool
@@ -30,26 +56,19 @@ export function extractActualToolName(
metaToolName: string,
toolInput: unknown,
): string | null {
// Check if this is a meta-tool
if (
metaToolName === META_TOOLS.DESCRIBE ||
metaToolName === META_TOOLS.EXECUTE
) {
// Meta-tool: Parse the JSON string in input.input
try {
if (
toolInput &&
typeof toolInput === "object" &&
"input" in toolInput &&
typeof toolInput.input === "string"
) {
const parsedInput = JSON.parse(toolInput.input);
return parsedInput.toolName || null;
}
} catch {
// Failed to parse, return null
return null;
}
const parsed = parseMetaToolInput(toolInput);
return (parsed?.toolName as string) || null;
}
if (metaToolName === META_TOOLS.LOAD_SKILL) {
const parsed = parseMetaToolInput(toolInput);
return parsed?.skillId
? `${SKILL_PREFIX}${parsed.skillId as string}`
: null;
}
// Actual tool execution: use the name directly
@@ -172,11 +191,18 @@ export function handleChatModelEndEvent(
const metaToolName = toolCall.name;
const toolArgs = toolCall.args;
// Extract actual tool name from toolArgs.toolName (camelCase)
const actualToolName =
toolArgs && typeof toolArgs === "object" && "toolName" in toolArgs
? (toolArgs.toolName as string)
: null;
// Extract actual tool name from toolArgs
let actualToolName: string | null = null;
if (toolArgs && typeof toolArgs === "object") {
if ("toolName" in toolArgs) {
actualToolName = toolArgs.toolName as string;
} else if (
metaToolName === META_TOOLS.LOAD_SKILL &&
"skillId" in toolArgs
) {
actualToolName = `${SKILL_PREFIX}${toolArgs.skillId as string}`;
}
}
controller.enqueue(
createChainOfThoughtEvent({

View File

@@ -6,6 +6,7 @@
export const META_TOOLS = {
DESCRIBE: "describe_tool",
EXECUTE: "execute_tool",
LOAD_SKILL: "load_skill",
} as const;
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 SKILL_PREFIX = "skill:";
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";

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
*/
import type { SkillMetadata } from "@/lib/lighthouse/skills/types";
export const LIGHTHOUSE_SYSTEM_PROMPT_TEMPLATE = `
## Introduction
@@ -45,7 +47,7 @@ You have access to tools from multiple sources:
## 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
- 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_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
- **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/
`;
/**
* 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
*/

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