diff --git a/README.md b/README.md index 957dac1..4de0dd1 100644 --- a/README.md +++ b/README.md @@ -83,15 +83,18 @@ php artisan vendor:publish --tag=agent-skills-config return [ 'skills' => [ 'enabled' => true, - 'agent' => \App\Agents\MyAgent::class, - 'directories' => [ - resource_path('skills'), + 'agents' => [ + 'my_agent' => [ + 'directories' => [ + resource_path('skills'), + ], + 'active_skills' => [ + 'twig-component', + 'laravel-console', + ], + 'include_index' => true, + ], ], - 'active_skills' => [ - 'twig-component', - 'symfony-console', - ], - 'include_index' => true, ], ]; ``` diff --git a/config/agent-skills.php b/config/agent-skills.php index 22870bc..85474c5 100644 --- a/config/agent-skills.php +++ b/config/agent-skills.php @@ -9,28 +9,27 @@ |-------------------------------------------------------------------------- | | Configure skill loading, active skills, and agent integration. - | When published in a Laravel app, you may use resource_path('skills') - | and storage_path('app/skill-evals') for default paths. + | Each agent can have its own set of skill directories, GitHub repositories, + | active skills, and index settings. | */ 'skills' => [ 'enabled' => env('AGENT_SKILLS_ENABLED', false), - 'agent' => env('AGENT_SKILLS_AGENT'), - - 'directories' => [ - // resource_path('skills'), - ], - - 'github_repositories' => [ - // ['repository' => 'owner/repo', 'path' => '', 'branch' => 'main', 'token' => null], + 'agents' => [ + // 'my_agent' => [ + // 'directories' => [ + // resource_path('skills'), + // ], + // 'github_repositories' => [ + // // ['repository' => 'owner/repo', 'path' => '', 'branch' => 'main', 'token' => null], + // ], + // 'active_skills' => [ + // // 'my-skill', + // ], + // 'include_index' => false, + // ], ], - - 'active_skills' => [ - // 'my-skill', - ], - - 'include_index' => false, ], /* diff --git a/doc/bundle.md b/doc/bundle.md index 92db554..7fc18c1 100644 --- a/doc/bundle.md +++ b/doc/bundle.md @@ -103,6 +103,10 @@ Each active skill is registered as a tool named ``skill_{name}`` (with dashes co underscores). The agent can call these tools to load skill content, reference files, and execute scripts on demand. +When a skill is loaded via a tool, the output automatically includes a **resource listing** +showing available scripts, references, and assets. This enables the agent to discover and +request specific resources by name without prior knowledge of the skill's directory contents. + ## Multi-Agent Configuration You can define multiple agents, each with their own dedicated skill sets: diff --git a/doc/laravel.md b/doc/laravel.md index 98bd5dd..ac3cb45 100644 --- a/doc/laravel.md +++ b/doc/laravel.md @@ -37,16 +37,17 @@ The full configuration file (``config/agent-skills.php``): return [ 'skills' => [ 'enabled' => env('AGENT_SKILLS_ENABLED', false), - 'agent' => env('AGENT_SKILLS_AGENT'), - 'directories' => [ - resource_path('skills'), + 'agents' => [ + // 'my_agent' => [ + // 'directories' => [resource_path('skills')], + // 'github_repositories' => [], + // 'active_skills' => ['my-skill'], + // 'include_index' => false, + // ], ], - 'github_repositories' => [], - 'active_skills' => [], - 'include_index' => false, ], 'evaluation' => [ - 'workspace' => storage_path('app/skill-evals'), + 'workspace' => env('AGENT_SKILLS_EVAL_WORKSPACE', 'storage/app/skill-evals'), 'grading_model' => env('AGENT_SKILLS_GRADING_MODEL'), 'grading_provider' => env('AGENT_SKILLS_GRADING_PROVIDER'), ], @@ -56,11 +57,11 @@ return [ Configuration options: * ``enabled`` (bool, default: ``false``): Enable or disable the skills integration -* ``agent`` (string, optional): Agent class name for tool registration and eval commands -* ``directories`` (array): Local directories to scan for skills -* ``github_repositories`` (array): GitHub repositories to load skills from -* ``active_skills`` (array): Skill names to fully load and register as tools -* ``include_index`` (bool, default: ``false``): Include a skill metadata index in prompts +* ``agents`` (array): Map of agent names to their configuration. Each agent entry supports: + * ``directories`` (array): Local directories to scan for skills + * ``github_repositories`` (array): GitHub repositories to load skills from + * ``active_skills`` (array): Skill names to fully load and register as tools + * ``include_index`` (bool, default: ``false``): Include a skill metadata index in prompts * ``workspace`` (string): Directory to store evaluation results * ``grading_model`` (string, optional): Model for LLM-based assertion grading * ``grading_provider`` (string, optional): Provider name for the grading model @@ -73,16 +74,19 @@ Skills stored in local directories are loaded by the filesystem loader: // config/agent-skills.php 'skills' => [ 'enabled' => true, - 'agent' => \App\Agents\MyAgent::class, - 'directories' => [ - resource_path('skills'), - base_path('vendor/my-org/shared-skills'), - ], - 'active_skills' => [ - 'twig-component', - 'laravel-console', + 'agents' => [ + 'my_agent' => [ + 'directories' => [ + resource_path('skills'), + base_path('vendor/my-org/shared-skills'), + ], + 'active_skills' => [ + 'twig-component', + 'laravel-console', + ], + 'include_index' => true, + ], ], - 'include_index' => true, ], ``` @@ -96,30 +100,33 @@ is automatically created to transparently compose both loaders: // config/agent-skills.php 'skills' => [ 'enabled' => true, - 'agent' => \App\Agents\MyAgent::class, - 'directories' => [ - resource_path('skills'), - ], - 'github_repositories' => [ - // Public repository - ['repository' => 'my-org/shared-skills'], - - // Private repository with authentication - [ - 'repository' => 'my-org/private-skills', - 'token' => env('GITHUB_TOKEN'), + 'agents' => [ + 'my_agent' => [ + 'directories' => [ + resource_path('skills'), + ], + 'github_repositories' => [ + // Public repository + ['repository' => 'my-org/shared-skills'], + + // Private repository with authentication + [ + 'repository' => 'my-org/private-skills', + 'token' => env('GITHUB_TOKEN'), + ], + + // Custom branch and subdirectory + [ + 'repository' => 'my-org/monorepo', + 'path' => 'ai/skills', + 'branch' => 'develop', + 'token' => env('GITHUB_TOKEN'), + ], + ], + 'active_skills' => [ + 'twig-component', + ], ], - - // Custom branch and subdirectory - [ - 'repository' => 'my-org/monorepo', - 'path' => 'ai/skills', - 'branch' => 'develop', - 'token' => env('GITHUB_TOKEN'), - ], - ], - 'active_skills' => [ - 'twig-component', ], ], ``` @@ -142,13 +149,16 @@ To use only GitHub-based skills without local directories: // config/agent-skills.php 'skills' => [ 'enabled' => true, - 'agent' => \App\Agents\MyAgent::class, - 'directories' => [], - 'github_repositories' => [ - ['repository' => 'my-org/skills'], - ], - 'active_skills' => [ - 'my-skill', + 'agents' => [ + 'my_agent' => [ + 'directories' => [], + 'github_repositories' => [ + ['repository' => 'my-org/skills'], + ], + 'active_skills' => [ + 'my-skill', + ], + ], ], ], ``` @@ -164,8 +174,12 @@ injected into your agents: * ``ExecuteSkillScriptTool``: Execute a script from the skill's ``scripts/`` directory Each active skill gets its own ``GetSkillTool`` and ``ExecuteSkillScriptTool`` instances, registered -in the container as ``agent_skills.tool.get_skill.{skill-name}`` and -``agent_skills.tool.execute_script.{skill-name}``. +in the container as ``agent_skills.tool.{agent}.{skill-name}`` and +``agent_skills.tool.{agent}.execute_script.{skill-name}``. + +When a skill is loaded via a tool, the output automatically includes a **resource listing** +showing available scripts, references, and assets. This enables the agent to discover and +request specific resources by name without prior knowledge of the skill's directory contents. To use skills as tools in your agent, implement the ``HasTools`` contract and return the tool instances: @@ -190,9 +204,9 @@ class MyAgent implements Agent, HasTools public function tools(): iterable { return [ - app()->make(GetSkillsTool::class), - app()->make('agent_skills.tool.get_skill.my-skill'), - app()->make('agent_skills.tool.execute_script.my-skill'), + app()->make('agent_skills.tool.my_agent.get_skills'), + app()->make('agent_skills.tool.my_agent.my-skill'), + app()->make('agent_skills.tool.my_agent.execute_script.my-skill'), ]; } } @@ -204,7 +218,8 @@ The ``SkillPromptMiddleware`` injects skill instructions directly into the agent providing contextual knowledge before the LLM call. This is the equivalent of Symfony AI's ``SkillInputProcessor``. -To attach the middleware to your agent, implement the ``HasMiddleware`` contract: +To attach the middleware to your agent, implement the ``HasMiddleware`` contract and resolve the +per-agent middleware from the container: ```php use AgentSkills\Bridge\Laravel\AI\Middleware\SkillPromptMiddleware; @@ -224,7 +239,7 @@ class MyAgent implements Agent, HasMiddleware public function middleware(): array { return [ - app()->make(SkillPromptMiddleware::class), + app()->make('agent_skills.my_agent.middleware'), ]; } } @@ -245,6 +260,48 @@ This approach is ideal when: * The skill content should influence all agent responses * You want the agent to follow specific guidelines or patterns +## Multi-Agent Configuration + +You can define multiple agents, each with their own dedicated skill sets: + +```php +// config/agent-skills.php +'skills' => [ + 'enabled' => true, + 'agents' => [ + 'code_reviewer' => [ + 'directories' => [ + resource_path('skills/review'), + ], + 'github_repositories' => [], + 'active_skills' => [ + 'code-review', + 'security-audit', + ], + 'include_index' => false, + ], + 'assistant' => [ + 'directories' => [ + resource_path('skills/assistant'), + ], + 'github_repositories' => [ + ['repository' => 'my-org/shared-skills'], + ], + 'active_skills' => [ + 'twig-component', + 'laravel-console', + ], + 'include_index' => true, + ], + ], +], +``` + +Each agent gets its own loader, middleware (``agent_skills.{agent}.middleware``), and tool +registrations (``agent_skills.tool.{agent}.{skill}``). When multiple agents are configured, +a global ``ChainSkillLoader`` is registered as the ``SkillLoaderInterface`` alias, composing +all per-agent loaders. + ## Skill Evaluation The evaluation system measures how well an agent performs with and without a skill. Configure @@ -276,7 +333,6 @@ registered when skills are enabled. See the `Commands`_ documentation for usage | Variable | Default | Description | |---|---|---| | ``AGENT_SKILLS_ENABLED`` | ``false`` | Enable or disable the skills integration | -| ``AGENT_SKILLS_AGENT`` | ``null`` | Agent class name for tool/eval registration | | ``AGENT_SKILLS_EVAL_WORKSPACE`` | ``storage/app/skill-evals`` | Evaluation workspace directory | | ``AGENT_SKILLS_GRADING_MODEL`` | ``null`` | LLM model for assertion grading | | ``AGENT_SKILLS_GRADING_PROVIDER`` | ``null`` | Provider for the grading model | diff --git a/doc/usage.md b/doc/usage.md index 5226c14..11c43d1 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -228,6 +228,10 @@ The :class:`Symfony\\AI\\Agent\\Toolbox\\Tool\\SkillTool` provides three built-i * ``get_skills``: Get all available skills * ``execute_skill_script``: Execute a script from the skill's ``scripts/`` directory +When a skill is loaded via ``get_skill`` or ``get_skills``, the output automatically includes +a resource listing section showing available scripts, references, and assets. This allows the +agent to discover what resources are available and request them by name. + Loading Skills with References .............................. @@ -454,8 +458,43 @@ The :class:`AgentSkills\\SkillLoaderInterface` provides methods for efficient sk Use Level 1 for listings, menus, or selection UIs. Use Level 2 when you need the actual skill content. -Evaluating Skills -^^^^^^^^^^^^^^^^^ +**Level 3 - Resource Enumeration** + +Once a skill is loaded (Level 2), you can list its available resources without knowing filenames upfront:: + + $skill = $loader->loadSkill('twig-component'); + + // List available resources by type + $scripts = $skill->listScripts(); // ['setup.sh', 'analyze.py'] + $references = $skill->listReferences(); // ['api-guide.md', 'patterns.md'] + $assets = $skill->listAssets(); // ['template.html'] + + // Get a formatted Markdown listing of all available resources + $listing = $skill->getResourceListing(); + +The ``getResourceListing()`` method returns a Markdown string that includes only non-empty +resource types:: + + ## Available Resources + + ### Scripts + - setup.sh + - analyze.py + + ### References + - api-guide.md + + ### Assets + - template.html + +If the skill has no resources, ``getResourceListing()`` returns an empty string. + +This resource listing is **automatically appended** to the skill output when skills are +loaded via tools (``SkillTool``, ``GetSkillTool``, ``GetSkillsTool``) or injected via context +processors (``SkillInputProcessor``, ``SkillPromptMiddleware``). The agent can then request +specific resources by name using ``loadScript()``, ``loadReference()``, or ``loadAsset()``. + +## Evaluating Skills The evaluation system measures how well an agent performs with and without a skill, using LLM-based grading of assertions. This helps you validate that a skill actually improves diff --git a/src/Bridge/Laravel/AI/AgentSkillsServiceProvider.php b/src/Bridge/Laravel/AI/AgentSkillsServiceProvider.php index 2f7b59a..053b3df 100644 --- a/src/Bridge/Laravel/AI/AgentSkillsServiceProvider.php +++ b/src/Bridge/Laravel/AI/AgentSkillsServiceProvider.php @@ -35,8 +35,12 @@ use Override; use Symfony\Contracts\HttpClient\HttpClientInterface; +use function array_map; +use function count; use function dirname; +use function is_array; use function is_string; +use function sprintf; /** * @author Guillaume Loulier @@ -59,9 +63,7 @@ public function register(): void } $this->registerCoreServices(); - $this->registerSkillLoaders(); - $this->registerMiddleware(); - $this->registerTools(); + $this->registerAgents(); $this->registerEvaluationServices(); } @@ -81,7 +83,10 @@ public function boot(): void $commands = [ValidateSkillCommand::class]; - if (null !== $this->config()->get('agent-skills.skills.agent')) { + /** @var array $agents */ + $agents = $this->config()->get('agent-skills.skills.agents', []); + + if ([] !== $agents) { $commands[] = EvalSkillCommand::class; } @@ -95,61 +100,127 @@ private function registerCoreServices(): void $this->app->singleton(SkillValidatorInterface::class, static fn (): SkillValidator => new SkillValidator()); } - private function registerSkillLoaders(): void + private function registerAgents(): void + { + /** @var array $agents */ + $agents = $this->config()->get('agent-skills.skills.agents', []); + $allLoaderKeys = []; + + foreach ($agents as $agentName => $agentConfig) { + if (!is_array($agentConfig)) { + continue; + } + + /** @var array $agentConfig */ + $effectiveLoaderKey = $this->registerAgentLoaders((string) $agentName, $agentConfig); + $allLoaderKeys[] = $effectiveLoaderKey; + + $this->registerAgentMiddleware((string) $agentName, $agentConfig, $effectiveLoaderKey); + $this->registerAgentTools((string) $agentName, $agentConfig, $effectiveLoaderKey); + } + + if (1 === count($allLoaderKeys)) { + $this->app->alias($allLoaderKeys[0], SkillLoaderInterface::class); + } elseif (count($allLoaderKeys) > 1) { + $this->app->singleton(SkillLoaderInterface::class, static fn ($app): ChainSkillLoader => new ChainSkillLoader( + array_map(static fn (string $key): SkillLoaderInterface => $app->make($key), $allLoaderKeys), + )); + } + } + + /** + * @param array $agentConfig + */ + private function registerAgentLoaders(string $agentName, array $agentConfig): string { /** @var array $directories */ - $directories = $this->config()->get('agent-skills.skills.directories', []); + $directories = is_array($agentConfig['directories'] ?? null) ? $agentConfig['directories'] : []; + + $fsLoaderId = sprintf('agent_skills.%s.filesystem_loader', $agentName); - $this->app->singleton('agent_skills.filesystem_loader', static fn ($app): FilesystemSkillLoader => new FilesystemSkillLoader( + $this->app->singleton($fsLoaderId, static fn ($app): FilesystemSkillLoader => new FilesystemSkillLoader( $directories, $app->make(SkillParserInterface::class), $app->make(SkillValidatorInterface::class), )); + $effectiveLoaderKey = $fsLoaderId; + /** @var array $githubRepositories */ - $githubRepositories = $this->config()->get('agent-skills.skills.github_repositories', []); + $githubRepositories = is_array($agentConfig['github_repositories'] ?? null) ? $agentConfig['github_repositories'] : []; if ([] !== $githubRepositories) { - $this->app->singleton('agent_skills.github_loader', static fn ($app): GithubSkillLoader => new GithubSkillLoader( + $ghLoaderId = sprintf('agent_skills.%s.github_loader', $agentName); + + $this->app->singleton($ghLoaderId, static fn ($app): GithubSkillLoader => new GithubSkillLoader( $githubRepositories, $app->make(HttpClientInterface::class), $app->make(SkillParserInterface::class), $app->make(SkillValidatorInterface::class), )); - $this->app->singleton(SkillLoaderInterface::class, static fn ($app): ChainSkillLoader => new ChainSkillLoader([ - $app->make('agent_skills.filesystem_loader'), - $app->make('agent_skills.github_loader'), - ])); - } else { - $this->app->alias('agent_skills.filesystem_loader', SkillLoaderInterface::class); + $chainLoaderId = sprintf('agent_skills.%s.chain_loader', $agentName); + + $this->app->singleton($chainLoaderId, static function ($app) use ($fsLoaderId, $ghLoaderId): ChainSkillLoader { + return new ChainSkillLoader([ + $app->make($fsLoaderId), + $app->make($ghLoaderId), + ]); + }); + + $effectiveLoaderKey = $chainLoaderId; } + + return $effectiveLoaderKey; } - private function registerMiddleware(): void + /** + * @param array $agentConfig + */ + private function registerAgentMiddleware(string $agentName, array $agentConfig, string $effectiveLoaderKey): void { /** @var array $activeSkills */ - $activeSkills = $this->config()->get('agent-skills.skills.active_skills', []); - $includeIndex = (bool) $this->config()->get('agent-skills.skills.include_index', false); - - $this->app->singleton(SkillPromptMiddleware::class, static fn ($app): SkillPromptMiddleware => new SkillPromptMiddleware( - $app->make(SkillLoaderInterface::class), - $activeSkills, - $includeIndex, - )); + $activeSkills = is_array($agentConfig['active_skills'] ?? null) ? $agentConfig['active_skills'] : []; + $includeIndex = (bool) ($agentConfig['include_index'] ?? false); + + $middlewareId = sprintf('agent_skills.%s.middleware', $agentName); + + $this->app->singleton($middlewareId, static function ($app) use ($effectiveLoaderKey, $activeSkills, $includeIndex): SkillPromptMiddleware { + return new SkillPromptMiddleware( + $app->make($effectiveLoaderKey), + $activeSkills, + $includeIndex, + ); + }); } - private function registerTools(): void + /** + * @param array $agentConfig + */ + private function registerAgentTools(string $agentName, array $agentConfig, string $effectiveLoaderKey): void { - /** @var array $activeSkills */ - $activeSkills = $this->config()->get('agent-skills.skills.active_skills', []); + /** @var mixed[] $activeSkills */ + $activeSkills = is_array($agentConfig['active_skills'] ?? null) ? $agentConfig['active_skills'] : []; - $this->app->singleton(GetSkillsTool::class, static fn ($app): GetSkillsTool => new GetSkillsTool($app->make(SkillLoaderInterface::class))); + $getSkillsToolId = sprintf('agent_skills.tool.%s.get_skills', $agentName); + $this->app->singleton($getSkillsToolId, static function ($app) use ($effectiveLoaderKey): GetSkillsTool { + return new GetSkillsTool($app->make($effectiveLoaderKey)); + }); foreach ($activeSkills as $skillName) { - $this->app->singleton('agent_skills.tool.get_skill.' . $skillName, static fn ($app): GetSkillTool => new GetSkillTool($app->make(SkillLoaderInterface::class), $skillName)); + if (!is_string($skillName) || '' === $skillName) { + continue; + } - $this->app->singleton('agent_skills.tool.execute_script.' . $skillName, static fn ($app): ExecuteSkillScriptTool => new ExecuteSkillScriptTool($app->make(SkillLoaderInterface::class), $skillName)); + $getSkillToolId = sprintf('agent_skills.tool.%s.%s', $agentName, $skillName); + $this->app->singleton($getSkillToolId, static function ($app) use ($effectiveLoaderKey, $skillName): GetSkillTool { + return new GetSkillTool($app->make($effectiveLoaderKey), $skillName); + }); + + $executeScriptToolId = sprintf('agent_skills.tool.%s.execute_script.%s', $agentName, $skillName); + $this->app->singleton($executeScriptToolId, static function ($app) use ($effectiveLoaderKey, $skillName): ExecuteSkillScriptTool { + return new ExecuteSkillScriptTool($app->make($effectiveLoaderKey), $skillName); + }); } } diff --git a/src/Bridge/Laravel/AI/Middleware/SkillPromptMiddleware.php b/src/Bridge/Laravel/AI/Middleware/SkillPromptMiddleware.php index 000ba8d..333b4f9 100644 --- a/src/Bridge/Laravel/AI/Middleware/SkillPromptMiddleware.php +++ b/src/Bridge/Laravel/AI/Middleware/SkillPromptMiddleware.php @@ -59,7 +59,7 @@ public function handle(AgentPrompt $prompt, Closure $next): mixed continue; } - $systemPromptParts[] = sprintf("## Skill: %s\n\n%s", $skill->getName(), $skill->getBody()); + $systemPromptParts[] = sprintf("## Skill: %s\n\n%s", $skill->getName(), $skill->getBody()) . $skill->getResourceListing(); } if ([] !== $systemPromptParts) { diff --git a/src/Bridge/Laravel/AI/Tool/GetSkillTool.php b/src/Bridge/Laravel/AI/Tool/GetSkillTool.php index d410e8a..6520c64 100644 --- a/src/Bridge/Laravel/AI/Tool/GetSkillTool.php +++ b/src/Bridge/Laravel/AI/Tool/GetSkillTool.php @@ -50,7 +50,7 @@ public function handle(Request $request): string return sprintf('Skill "%s" not found.', $this->skillName); } - $output = sprintf("# Skill: %s\n\n%s", $skill->getName(), $skill->getBody()); + $output = sprintf("# Skill: %s\n\n%s", $skill->getName(), $skill->getBody()) . $skill->getResourceListing(); $reference = $request['reference'] ?? null; if (is_string($reference) && '' !== $reference) { diff --git a/src/Bridge/Laravel/AI/Tool/GetSkillsTool.php b/src/Bridge/Laravel/AI/Tool/GetSkillsTool.php index 82a1f8a..f8240b9 100644 --- a/src/Bridge/Laravel/AI/Tool/GetSkillsTool.php +++ b/src/Bridge/Laravel/AI/Tool/GetSkillsTool.php @@ -47,7 +47,7 @@ public function handle(Request $request): string } $formatted = array_values(array_map( - static fn (SkillInterface $skill): string => sprintf("# Skill: %s\n\n%s", $skill->getName(), $skill->getBody()), + static fn (SkillInterface $skill): string => sprintf("# Skill: %s\n\n%s", $skill->getName(), $skill->getBody()) . $skill->getResourceListing(), $skills, )); diff --git a/src/Bridge/Symfony/AI/DependencyInjection/AgentSkillBundleExtension.php b/src/Bridge/Symfony/AI/DependencyInjection/AgentSkillBundleExtension.php index ce81433..fb75c2b 100644 --- a/src/Bridge/Symfony/AI/DependencyInjection/AgentSkillBundleExtension.php +++ b/src/Bridge/Symfony/AI/DependencyInjection/AgentSkillBundleExtension.php @@ -94,7 +94,19 @@ public function load(array $configs, ContainerBuilder $container): void $allEffectiveLoaderRefs[] = new Reference($traceableLoaderId); - $this->registerAgentInputProcessor($container, $agentName, $agentConfig, $traceableLoaderId); + $container->setDefinition(sprintf('agent_skills.%s.input_processor', $agentName), (new Definition(SkillInputProcessor::class)) + ->setLazy(true) + ->setArguments([ + new Reference($traceableLoaderId), + array_map( + static fn (mixed $skill): string => is_array($skill) && is_string($skill['name'] ?? null) ? $skill['name'] : '', + is_array($agentConfig['active_skills'] ?? null) ? $agentConfig['active_skills'] : [], + ), + $agentConfig['include_index'] ?? false, + ]) + ->addTag('proxy', ['interface' => InputProcessorInterface::class]) + ->addTag('ai.agent.input_processor', ['agent' => $agentName, 'priority' => -50])); + $this->registerAgentTools($container, $agentName, $agentConfig, $traceableLoaderId); } @@ -158,33 +170,6 @@ private function registerAgentLoaders(ContainerBuilder $container, string $agent return $effectiveLoaderId; } - /** - * @param array $agentConfig - */ - private function registerAgentInputProcessor(ContainerBuilder $container, string $agentName, array $agentConfig, string $effectiveLoaderId): void - { - $activeSkills = is_array($agentConfig['active_skills'] ?? null) ? $agentConfig['active_skills'] : []; - $includeIndex = (bool) ($agentConfig['include_index'] ?? false); - - /** @var list $activeSkillNames */ - $activeSkillNames = array_map( - static fn (mixed $skill): string => is_array($skill) && is_string($skill['name'] ?? null) ? $skill['name'] : '', - $activeSkills, - ); - - $inputProcessorId = sprintf('agent_skills.%s.input_processor', $agentName); - - $container->setDefinition($inputProcessorId, (new Definition(SkillInputProcessor::class)) - ->setLazy(true) - ->setArguments([ - new Reference($effectiveLoaderId), - $activeSkillNames, - $includeIndex, - ]) - ->addTag('proxy', ['interface' => InputProcessorInterface::class]) - ->addTag('ai.agent.input_processor', ['agent' => $agentName, 'priority' => -50])); - } - /** * @param array $agentConfig */ diff --git a/src/Bridge/Symfony/AI/SkillInputProcessor.php b/src/Bridge/Symfony/AI/SkillInputProcessor.php index 2b1b977..b068c27 100644 --- a/src/Bridge/Symfony/AI/SkillInputProcessor.php +++ b/src/Bridge/Symfony/AI/SkillInputProcessor.php @@ -60,7 +60,7 @@ public function processInput(Input $input): void continue; } - $systemPromptParts[] = sprintf("## Skill: %s\n\n%s", $skill->getName(), $skill->getBody()); + $systemPromptParts[] = sprintf("## Skill: %s\n\n%s", $skill->getName(), $skill->getBody()) . $skill->getResourceListing(); } $options = $input->getOptions(); diff --git a/src/Bridge/Symfony/AI/SkillTool.php b/src/Bridge/Symfony/AI/SkillTool.php index 7ca44a9..47277c6 100644 --- a/src/Bridge/Symfony/AI/SkillTool.php +++ b/src/Bridge/Symfony/AI/SkillTool.php @@ -44,7 +44,7 @@ public function loadSkill(?string $reference = null): string return sprintf('Skill "%s" not found.', $this->skillName); } - $output = sprintf("# Skill: %s\n\n%s", $skill->getName(), $skill->getBody()); + $output = sprintf("# Skill: %s\n\n%s", $skill->getName(), $skill->getBody()) . $skill->getResourceListing(); if (null !== $reference) { try { @@ -72,7 +72,7 @@ public function loadSkills(): array } return array_map( - static fn (SkillInterface $skill): string => sprintf("# Skill: %s\n\n%s", $skill->getName(), $skill->getBody()), + static fn (SkillInterface $skill): string => sprintf("# Skill: %s\n\n%s", $skill->getName(), $skill->getBody()) . $skill->getResourceListing(), $skills, ); } diff --git a/src/GithubSkillLoader.php b/src/GithubSkillLoader.php index 5c19abc..996f6e3 100644 --- a/src/GithubSkillLoader.php +++ b/src/GithubSkillLoader.php @@ -68,6 +68,9 @@ public function loadSkill(string $name): ?SkillInterface $this->createScriptsLoader($config, $name), $this->createReferencesLoader($config, $name), $this->createAssetsLoader($config, $name), + $this->createScriptsLister($config, $name), + $this->createReferencesLister($config, $name), + $this->createAssetsLister($config, $name), ); if ($skill->getName() !== $name) { @@ -112,6 +115,9 @@ public function loadSkills(): array $this->createScriptsLoader($config, $skillName), $this->createReferencesLoader($config, $skillName), $this->createAssetsLoader($config, $skillName), + $this->createScriptsLister($config, $skillName), + $this->createReferencesLister($config, $skillName), + $this->createAssetsLister($config, $skillName), ); $validation = $this->skillValidator->validate($skill); @@ -283,6 +289,68 @@ private function createAssetsLoader(array $config, string $skillName): Closure }; } + /** + * @param array{repository: string, path: string, branch: string, token: string|null} $config + */ + private function createScriptsLister(array $config, string $skillName): Closure + { + return fn (): array => $this->listDirectoryContents($config, $this->buildSkillPath($config['path'], $skillName, 'scripts')); + } + + /** + * @param array{repository: string, path: string, branch: string, token: string|null} $config + */ + private function createReferencesLister(array $config, string $skillName): Closure + { + return fn (): array => $this->listDirectoryContents($config, $this->buildSkillPath($config['path'], $skillName, 'references')); + } + + /** + * @param array{repository: string, path: string, branch: string, token: string|null} $config + */ + private function createAssetsLister(array $config, string $skillName): Closure + { + return fn (): array => $this->listDirectoryContents($config, $this->buildSkillPath($config['path'], $skillName, 'assets')); + } + + /** + * Lists file names in a directory via the GitHub Contents API. + * + * @param array{repository: string, path: string, branch: string, token: string|null} $config + * + * @return string[] + */ + private function listDirectoryContents(array $config, string $path): array + { + [$owner, $repo] = explode('/', $config['repository'], 2); + + $url = sprintf('%s/repos/%s/%s/contents/%s', self::GITHUB_API_BASE, $owner, $repo, $path); + + try { + $response = $this->httpClient->request('GET', $url, [ + 'headers' => $this->buildHeaders($config['token']), + 'query' => ['ref' => $config['branch']], + ]); + + if (200 !== $response->getStatusCode()) { + return []; + } + + $entries = $response->toArray(); + $files = []; + + foreach ($entries as $entry) { + if ('file' === ($entry['type'] ?? null) && is_string($entry['name'] ?? null)) { + $files[] = $entry['name']; + } + } + + return $files; + } catch (Throwable) { + return []; + } + } + private function buildSkillPath(string $basePath, string $skillName, string $file): string { $parts = array_filter([$basePath, $skillName, $file], static fn (string $p): bool => '' !== $p); diff --git a/src/Skill.php b/src/Skill.php index 008b803..934e137 100644 --- a/src/Skill.php +++ b/src/Skill.php @@ -7,6 +7,9 @@ use AgentSkills\Exception\RuntimeException; use Closure; +use function implode; +use function sprintf; + /** * Represents a fully loaded Agent Skill. * @@ -25,6 +28,9 @@ public function __construct( private ?Closure $scriptsLoader = null, private ?Closure $referencesLoader = null, private ?Closure $assetsLoader = null, + private ?Closure $scriptsLister = null, + private ?Closure $referencesLister = null, + private ?Closure $assetsLister = null, ) { } @@ -81,4 +87,79 @@ public function loadAsset(string $asset): mixed return ($this->assetsLoader)($asset); } + + /** + * @return string[] + */ + public function listScripts(): array + { + if (!$this->scriptsLister instanceof Closure) { + return []; + } + + return ($this->scriptsLister)(); + } + + /** + * @return string[] + */ + public function listReferences(): array + { + if (!$this->referencesLister instanceof Closure) { + return []; + } + + return ($this->referencesLister)(); + } + + /** + * @return string[] + */ + public function listAssets(): array + { + if (!$this->assetsLister instanceof Closure) { + return []; + } + + return ($this->assetsLister)(); + } + + public function getResourceListing(): string + { + $scripts = $this->listScripts(); + $references = $this->listReferences(); + $assets = $this->listAssets(); + + if ([] === $scripts && [] === $references && [] === $assets) { + return ''; + } + + $sections = []; + + if ([] !== $scripts) { + $items = ''; + foreach ($scripts as $script) { + $items .= sprintf("- %s\n", $script); + } + $sections[] = "### Scripts\n" . $items; + } + + if ([] !== $references) { + $items = ''; + foreach ($references as $reference) { + $items .= sprintf("- %s\n", $reference); + } + $sections[] = "### References\n" . $items; + } + + if ([] !== $assets) { + $items = ''; + foreach ($assets as $asset) { + $items .= sprintf("- %s\n", $asset); + } + $sections[] = "### Assets\n" . $items; + } + + return "\n\n## Available Resources\n\n" . implode("\n", $sections); + } } diff --git a/src/SkillInterface.php b/src/SkillInterface.php index adea033..62f84de 100644 --- a/src/SkillInterface.php +++ b/src/SkillInterface.php @@ -22,4 +22,26 @@ public function loadScript(string $script): mixed; public function loadReference(string $reference): mixed; public function loadAsset(string $asset): mixed; + + /** + * @return string[] List of available script filenames + */ + public function listScripts(): array; + + /** + * @return string[] List of available reference filenames + */ + public function listReferences(): array; + + /** + * @return string[] List of available asset filenames + */ + public function listAssets(): array; + + /** + * Returns a formatted Markdown listing of all available resources. + * + * Used for structured activation output per the AgentSkills spec. + */ + public function getResourceListing(): string; } diff --git a/src/SkillParser.php b/src/SkillParser.php index 28234ef..25e92e6 100644 --- a/src/SkillParser.php +++ b/src/SkillParser.php @@ -8,6 +8,7 @@ use AgentSkills\Exception\RuntimeException; use Closure; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Finder; use Symfony\Component\String\UnicodeString; use function array_filter; @@ -90,6 +91,9 @@ function (string $asset) use ($directory): ?string { return $this->filesystem->readFile($path . '/' . $asset); }, + $this->createDirectoryLister($directory . '/scripts'), + $this->createDirectoryLister($directory . '/references'), + $this->createDirectoryLister($directory . '/assets'), ); } @@ -114,12 +118,15 @@ public function parseFromContent( ?Closure $scriptsLoader = null, ?Closure $referencesLoader = null, ?Closure $assetsLoader = null, + ?Closure $scriptsLister = null, + ?Closure $referencesLister = null, + ?Closure $assetsLister = null, ): SkillInterface { [$frontmatter, $body] = $this->extractFrontmatter($content, $source); $metadata = $this->buildMetadata($frontmatter, $source); - return new Skill($body, $metadata, $scriptsLoader, $referencesLoader, $assetsLoader); + return new Skill($body, $metadata, $scriptsLoader, $referencesLoader, $assetsLoader, $scriptsLister, $referencesLister, $assetsLister); } public function parseMetadataFromContent(string $content, string $source): SkillMetadataInterface @@ -191,6 +198,29 @@ private function buildMetadata(array $frontmatter, string $file): SkillMetadata ); } + /** + * Creates a closure that lists filenames in a directory. + * + * @return Closure(): string[] + */ + private function createDirectoryLister(string $directoryPath): Closure + { + return static function () use ($directoryPath): array { + if (!is_dir($directoryPath)) { + return []; + } + + $finder = (new Finder())->files()->in($directoryPath)->depth(0)->sortByName(); + $names = []; + + foreach ($finder as $file) { + $names[] = $file->getFilename(); + } + + return $names; + }; + } + /** * Simple YAML parser for flat/nested structures (no external dependency on symfony/yaml needed). * diff --git a/src/SkillParserInterface.php b/src/SkillParserInterface.php index 63b63ad..bc3869e 100644 --- a/src/SkillParserInterface.php +++ b/src/SkillParserInterface.php @@ -29,16 +29,19 @@ public function parse(string $directory): SkillInterface; public function parseMetadataOnly(string $directory): SkillMetadataInterface; /** - * Parses raw SKILL.md content with optional resource loaders. + * Parses raw SKILL.md content with optional resource loaders and listers. * * This enables source-agnostic parsing (e.g. from GitHub, database, etc.) - * where the caller provides the content and closures for loading sub-resources. + * where the caller provides the content and closures for loading/listing sub-resources. * * @param string $content Raw SKILL.md content (frontmatter + body) * @param string $source Source identifier for error messages * @param Closure|null $scriptsLoader fn(string $script): string * @param Closure|null $referencesLoader fn(string $reference): ?string * @param Closure|null $assetsLoader fn(string $asset): ?string + * @param Closure|null $scriptsLister fn(): string[] + * @param Closure|null $referencesLister fn(): string[] + * @param Closure|null $assetsLister fn(): string[] * * @throws InvalidArgumentException If content is malformed */ @@ -48,6 +51,9 @@ public function parseFromContent( ?Closure $scriptsLoader = null, ?Closure $referencesLoader = null, ?Closure $assetsLoader = null, + ?Closure $scriptsLister = null, + ?Closure $referencesLister = null, + ?Closure $assetsLister = null, ): SkillInterface; /** diff --git a/tests/Bridge/Laravel/AI/AgentSkillsServiceProviderTest.php b/tests/Bridge/Laravel/AI/AgentSkillsServiceProviderTest.php index 9004bb6..dd5fae7 100644 --- a/tests/Bridge/Laravel/AI/AgentSkillsServiceProviderTest.php +++ b/tests/Bridge/Laravel/AI/AgentSkillsServiceProviderTest.php @@ -5,8 +5,7 @@ namespace AgentSkills\Tests\Bridge\Laravel\AI; use AgentSkills\Bridge\Laravel\AI\AgentSkillsServiceProvider; -use AgentSkills\Bridge\Laravel\AI\Middleware\SkillPromptMiddleware; -use AgentSkills\Bridge\Laravel\AI\Tool\GetSkillsTool; +use AgentSkills\ChainSkillLoader; use AgentSkills\Evaluation\Aggregator\BenchmarkAggregatorInterface; use AgentSkills\Evaluation\EvalSuiteLoaderInterface; use AgentSkills\Evaluation\Grader\GraderInterface; @@ -60,14 +59,14 @@ public function testRegistersCoreSingletons(): void $this->assertTrue($this->app->bound(SkillValidatorInterface::class)); } - public function testRegistersFilesystemLoader(): void + public function testRegistersPerAgentFilesystemLoader(): void { $this->configureEnabled(); $provider = new AgentSkillsServiceProvider($this->app); $provider->register(); - $this->assertTrue($this->app->bound('agent_skills.filesystem_loader')); + $this->assertTrue($this->app->bound('agent_skills.default.filesystem_loader')); } public function testRegistersSkillLoaderAlias(): void @@ -80,37 +79,37 @@ public function testRegistersSkillLoaderAlias(): void $this->assertTrue($this->app->bound(SkillLoaderInterface::class) || $this->app->isAlias(SkillLoaderInterface::class)); } - public function testRegistersMiddleware(): void + public function testRegistersPerAgentMiddleware(): void { $this->configureEnabled(); $provider = new AgentSkillsServiceProvider($this->app); $provider->register(); - $this->assertTrue($this->app->bound(SkillPromptMiddleware::class)); + $this->assertTrue($this->app->bound('agent_skills.default.middleware')); } - public function testRegistersGetSkillsTool(): void + public function testRegistersPerAgentGetSkillsTool(): void { $this->configureEnabled(); $provider = new AgentSkillsServiceProvider($this->app); $provider->register(); - $this->assertTrue($this->app->bound(GetSkillsTool::class)); + $this->assertTrue($this->app->bound('agent_skills.tool.default.get_skills')); } - public function testRegistersPerSkillTools(): void + public function testRegistersPerAgentPerSkillTools(): void { $this->configureEnabled(['active_skills' => ['code-review', 'testing']]); $provider = new AgentSkillsServiceProvider($this->app); $provider->register(); - $this->assertTrue($this->app->bound('agent_skills.tool.get_skill.code-review')); - $this->assertTrue($this->app->bound('agent_skills.tool.execute_script.code-review')); - $this->assertTrue($this->app->bound('agent_skills.tool.get_skill.testing')); - $this->assertTrue($this->app->bound('agent_skills.tool.execute_script.testing')); + $this->assertTrue($this->app->bound('agent_skills.tool.default.code-review')); + $this->assertTrue($this->app->bound('agent_skills.tool.default.execute_script.code-review')); + $this->assertTrue($this->app->bound('agent_skills.tool.default.testing')); + $this->assertTrue($this->app->bound('agent_skills.tool.default.execute_script.testing')); } public function testRegistersEvaluationServices(): void @@ -145,20 +144,130 @@ public function testRegistersGraderWhenConfigured(): void $this->assertTrue($this->app->bound(GraderInterface::class)); } + public function testMultipleAgentsEachGetOwnLoader(): void + { + $this->config->set('agent-skills.skills', [ + 'enabled' => true, + 'agents' => [ + 'agent_one' => [ + 'directories' => [sys_get_temp_dir()], + 'github_repositories' => [], + 'active_skills' => [], + 'include_index' => false, + ], + 'agent_two' => [ + 'directories' => [sys_get_temp_dir()], + 'github_repositories' => [], + 'active_skills' => [], + 'include_index' => false, + ], + ], + ]); + $this->config->set('agent-skills.evaluation', [ + 'workspace' => sys_get_temp_dir() . '/skill-evals', + 'grading_model' => null, + 'grading_provider' => null, + ]); + + $provider = new AgentSkillsServiceProvider($this->app); + $provider->register(); + + $this->assertTrue($this->app->bound('agent_skills.agent_one.filesystem_loader')); + $this->assertTrue($this->app->bound('agent_skills.agent_two.filesystem_loader')); + $this->assertTrue($this->app->bound('agent_skills.agent_one.middleware')); + $this->assertTrue($this->app->bound('agent_skills.agent_two.middleware')); + $this->assertTrue($this->app->bound('agent_skills.tool.agent_one.get_skills')); + $this->assertTrue($this->app->bound('agent_skills.tool.agent_two.get_skills')); + } + + public function testMultipleAgentsGetGlobalChainLoader(): void + { + $this->config->set('agent-skills.skills', [ + 'enabled' => true, + 'agents' => [ + 'agent_one' => [ + 'directories' => [sys_get_temp_dir()], + 'github_repositories' => [], + 'active_skills' => [], + 'include_index' => false, + ], + 'agent_two' => [ + 'directories' => [sys_get_temp_dir()], + 'github_repositories' => [], + 'active_skills' => [], + 'include_index' => false, + ], + ], + ]); + $this->config->set('agent-skills.evaluation', [ + 'workspace' => sys_get_temp_dir() . '/skill-evals', + 'grading_model' => null, + 'grading_provider' => null, + ]); + + $provider = new AgentSkillsServiceProvider($this->app); + $provider->register(); + + $this->assertTrue($this->app->bound(SkillLoaderInterface::class)); + + $loader = $this->app->make(SkillLoaderInterface::class); + $this->assertInstanceOf(ChainSkillLoader::class, $loader); + } + + public function testPerAgentToolsWithMultipleAgents(): void + { + $this->config->set('agent-skills.skills', [ + 'enabled' => true, + 'agents' => [ + 'reviewer' => [ + 'directories' => [sys_get_temp_dir()], + 'github_repositories' => [], + 'active_skills' => ['code-review'], + 'include_index' => false, + ], + 'assistant' => [ + 'directories' => [sys_get_temp_dir()], + 'github_repositories' => [], + 'active_skills' => ['twig-component'], + 'include_index' => true, + ], + ], + ]); + $this->config->set('agent-skills.evaluation', [ + 'workspace' => sys_get_temp_dir() . '/skill-evals', + 'grading_model' => null, + 'grading_provider' => null, + ]); + + $provider = new AgentSkillsServiceProvider($this->app); + $provider->register(); + + $this->assertTrue($this->app->bound('agent_skills.tool.reviewer.code-review')); + $this->assertTrue($this->app->bound('agent_skills.tool.reviewer.execute_script.code-review')); + $this->assertFalse($this->app->bound('agent_skills.tool.reviewer.twig-component')); + + $this->assertTrue($this->app->bound('agent_skills.tool.assistant.twig-component')); + $this->assertTrue($this->app->bound('agent_skills.tool.assistant.execute_script.twig-component')); + $this->assertFalse($this->app->bound('agent_skills.tool.assistant.code-review')); + } + /** - * @param array $skillOverrides + * @param array $agentOverrides * @param array $evalOverrides */ - private function configureEnabled(array $skillOverrides = [], array $evalOverrides = []): void + private function configureEnabled(array $agentOverrides = [], array $evalOverrides = []): void { $skills = [ 'enabled' => true, - 'agent' => null, - 'directories' => [sys_get_temp_dir()], - 'github_repositories' => [], - 'active_skills' => [], - 'include_index' => false, - ...$skillOverrides, + 'agents' => [ + 'default' => [ + 'directories' => [sys_get_temp_dir()], + 'github_repositories' => [], + 'active_skills' => [], + 'include_index' => false, + ...$agentOverrides, + ], + ], ]; $evaluation = [ diff --git a/tests/Bridge/Symfony/AI/SkillInputProcessorTest.php b/tests/Bridge/Symfony/AI/SkillInputProcessorTest.php index 302ad45..34a361a 100644 --- a/tests/Bridge/Symfony/AI/SkillInputProcessorTest.php +++ b/tests/Bridge/Symfony/AI/SkillInputProcessorTest.php @@ -153,6 +153,29 @@ public function testProcessInputAppendsToExistingSystemPrompt(): void $this->assertStringContainsString('# Agent Skills', $systemPrompt); } + public function testProcessInputIncludesResourceListingForActiveSkills(): void + { + $this->createSkillDirectory('code-review', 'Reviews code'); + $skillDir = $this->tempDir . '/code-review'; + (new Filesystem())->mkdir($skillDir . '/scripts'); + (new Filesystem())->dumpFile($skillDir . '/scripts/lint.sh', '#!/bin/bash'); + (new Filesystem())->mkdir($skillDir . '/references'); + (new Filesystem())->dumpFile($skillDir . '/references/style-guide.md', '# Style'); + + $discovery = new FilesystemSkillLoader([$this->tempDir]); + $processor = new SkillInputProcessor($discovery, activeSkills: ['code-review'], includeIndex: false); + + $input = new Input('gpt-4o', new MessageBag(Message::ofUser('Hello'))); + $processor->processInput($input); + + $options = $input->getOptions(); + $systemPrompt = $options['system_prompt']; + $this->assertIsString($systemPrompt); + $this->assertStringContainsString('## Available Resources', $systemPrompt); + $this->assertStringContainsString('- lint.sh', $systemPrompt); + $this->assertStringContainsString('- style-guide.md', $systemPrompt); + } + private function createSkillDirectory(string $name, string $description): void { $skillDir = $this->tempDir . '/' . $name; diff --git a/tests/SkillParserTest.php b/tests/SkillParserTest.php index 4c7b5df..1073678 100644 --- a/tests/SkillParserTest.php +++ b/tests/SkillParserTest.php @@ -383,6 +383,104 @@ public function testLoadAssetBuildsCorrectPath(): void $this->assertSame('Asset content', $skill->loadAsset('template.txt')); } + public function testParseCreatesScriptsLister(): void + { + $skillDir = $this->createSkillFile(<<<'MD' + --- + name: lister-skill + description: A skill with scripts + --- + Body. + MD); + + (new Filesystem())->mkdir($skillDir . '/scripts'); + (new Filesystem())->dumpFile($skillDir . '/scripts/setup.sh', '#!/bin/bash'); + (new Filesystem())->dumpFile($skillDir . '/scripts/analyze.py', '# python'); + + $skill = (new SkillParser())->parse($skillDir); + + $scripts = $skill->listScripts(); + $this->assertCount(2, $scripts); + $this->assertContains('analyze.py', $scripts); + $this->assertContains('setup.sh', $scripts); + } + + public function testParseCreatesReferencesLister(): void + { + $skillDir = $this->createSkillFile(<<<'MD' + --- + name: ref-lister + description: A skill with references + --- + Body. + MD); + + (new Filesystem())->mkdir($skillDir . '/references'); + (new Filesystem())->dumpFile($skillDir . '/references/guide.md', '# Guide'); + + $skill = (new SkillParser())->parse($skillDir); + + $this->assertSame(['guide.md'], $skill->listReferences()); + } + + public function testParseCreatesAssetsLister(): void + { + $skillDir = $this->createSkillFile(<<<'MD' + --- + name: asset-lister + description: A skill with assets + --- + Body. + MD); + + (new Filesystem())->mkdir($skillDir . '/assets'); + (new Filesystem())->dumpFile($skillDir . '/assets/logo.png', 'fake-png'); + (new Filesystem())->dumpFile($skillDir . '/assets/template.html', ''); + + $skill = (new SkillParser())->parse($skillDir); + + $assets = $skill->listAssets(); + $this->assertCount(2, $assets); + $this->assertContains('logo.png', $assets); + $this->assertContains('template.html', $assets); + } + + public function testParseReturnsEmptyListWhenNoResourceDirectories(): void + { + $skillDir = $this->createSkillFile(<<<'MD' + --- + name: no-resources + description: A skill without resource directories + --- + Body. + MD); + + $skill = (new SkillParser())->parse($skillDir); + + $this->assertSame([], $skill->listScripts()); + $this->assertSame([], $skill->listReferences()); + $this->assertSame([], $skill->listAssets()); + $this->assertSame('', $skill->getResourceListing()); + } + + public function testParseFromContentWithCustomListers(): void + { + $content = "---\nname: remote-skill\ndescription: A skill with remote listers\n---\nBody."; + + $skill = (new SkillParser())->parseFromContent( + $content, + 'github://owner/repo/remote-skill', + scriptsLister: static fn (): array => ['deploy.sh'], + referencesLister: static fn (): array => ['api.md'], + assetsLister: static fn (): array => ['logo.svg'], + ); + + $this->assertSame(['deploy.sh'], $skill->listScripts()); + $this->assertSame(['api.md'], $skill->listReferences()); + $this->assertSame(['logo.svg'], $skill->listAssets()); + $this->assertStringContainsString('## Available Resources', $skill->getResourceListing()); + } + /** * Creates a SKILL.md file in a subdirectory matching the skill name. * diff --git a/tests/SkillTest.php b/tests/SkillTest.php index d14d1dc..f9ad482 100644 --- a/tests/SkillTest.php +++ b/tests/SkillTest.php @@ -87,4 +87,114 @@ public function testLoadAssetReturnsPath(): void $this->assertSame($assetPath, $skill->loadAsset('logo.png')); } + + public function testListScriptsReturnsEmptyWithoutLister(): void + { + $skill = new Skill('body', new SkillMetadata('my-skill', 'A skill')); + + $this->assertSame([], $skill->listScripts()); + } + + public function testListScriptsReturnsFilenames(): void + { + $skill = new Skill( + 'body', + new SkillMetadata('my-skill', 'A skill'), + scriptsLister: static fn (): array => ['setup.sh', 'analyze.py'], + ); + + $this->assertSame(['setup.sh', 'analyze.py'], $skill->listScripts()); + } + + public function testListReferencesReturnsEmptyWithoutLister(): void + { + $skill = new Skill('body', new SkillMetadata('my-skill', 'A skill')); + + $this->assertSame([], $skill->listReferences()); + } + + public function testListReferencesReturnsFilenames(): void + { + $skill = new Skill( + 'body', + new SkillMetadata('my-skill', 'A skill'), + referencesLister: static fn (): array => ['api-guide.md'], + ); + + $this->assertSame(['api-guide.md'], $skill->listReferences()); + } + + public function testListAssetsReturnsEmptyWithoutLister(): void + { + $skill = new Skill('body', new SkillMetadata('my-skill', 'A skill')); + + $this->assertSame([], $skill->listAssets()); + } + + public function testListAssetsReturnsFilenames(): void + { + $skill = new Skill( + 'body', + new SkillMetadata('my-skill', 'A skill'), + assetsLister: static fn (): array => ['template.html', 'logo.png'], + ); + + $this->assertSame(['template.html', 'logo.png'], $skill->listAssets()); + } + + public function testGetResourceListingReturnsEmptyWhenNoResources(): void + { + $skill = new Skill('body', new SkillMetadata('my-skill', 'A skill')); + + $this->assertSame('', $skill->getResourceListing()); + } + + public function testGetResourceListingWithAllResourceTypes(): void + { + $skill = new Skill( + 'body', + new SkillMetadata('my-skill', 'A skill'), + scriptsLister: static fn (): array => ['setup.sh'], + referencesLister: static fn (): array => ['api-guide.md'], + assetsLister: static fn (): array => ['template.html'], + ); + + $listing = $skill->getResourceListing(); + + $this->assertStringContainsString('## Available Resources', $listing); + $this->assertStringContainsString('### Scripts', $listing); + $this->assertStringContainsString('- setup.sh', $listing); + $this->assertStringContainsString('### References', $listing); + $this->assertStringContainsString('- api-guide.md', $listing); + $this->assertStringContainsString('### Assets', $listing); + $this->assertStringContainsString('- template.html', $listing); + } + + public function testGetResourceListingWithOnlyScripts(): void + { + $skill = new Skill( + 'body', + new SkillMetadata('my-skill', 'A skill'), + scriptsLister: static fn (): array => ['setup.sh'], + ); + + $listing = $skill->getResourceListing(); + + $this->assertStringContainsString('### Scripts', $listing); + $this->assertStringNotContainsString('### References', $listing); + $this->assertStringNotContainsString('### Assets', $listing); + } + + public function testGetResourceListingWithEmptyListersReturnsEmpty(): void + { + $skill = new Skill( + 'body', + new SkillMetadata('my-skill', 'A skill'), + scriptsLister: static fn (): array => [], + referencesLister: static fn (): array => [], + assetsLister: static fn (): array => [], + ); + + $this->assertSame('', $skill->getResourceListing()); + } }