diff --git a/skills-lock.json b/skills-lock.json index 57a682a..0ecda56 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -5,6 +5,11 @@ "source": "github/awesome-copilot", "sourceType": "github", "computedHash": "e24e3c4cab4b5a07bc1d1801db14868f3a1cd184f7f28f5dd2c4a2b234bf2f2d" + }, + "rfc-specification": { + "source": "jpoutrin/product-forge", + "sourceType": "github", + "computedHash": "7beaec93d328ba8cb90105c919d2da3f732000a4dfc4bd9168cbd0ce1b19e0af" } } } diff --git a/src/llama_agent_skills/__init__.py b/src/llama_agent_skills/__init__.py index c9d3929..8980ce5 100644 --- a/src/llama_agent_skills/__init__.py +++ b/src/llama_agent_skills/__init__.py @@ -7,9 +7,10 @@ from pathlib import Path from dotenv import load_dotenv -from llama_agent_skills.agent import create_agent, run_agent +from llama_agent_skills.agent import create_agent, run_agent, run_agent_streaming from llama_agent_skills.config import Config from llama_agent_skills.registry import SkillRegistry +from llama_agent_skills.tools import create_skill_tools def _parse_args() -> argparse.Namespace: @@ -27,8 +28,10 @@ def _parse_args() -> argparse.Namespace: async def _run_interactive(config: Config, registry: SkillRegistry) -> None: skills = registry.list_skills() + skill_tools = create_skill_tools(registry) if skills else [] agent = create_agent( skills=skills, + tools=skill_tools, model=config.llm_model, api_key=config.llm_api_key, base_url=config.llm_base_url or None, @@ -52,8 +55,8 @@ async def _run_interactive(config: Config, registry: SkillRegistry) -> None: print("Bye!") break - response = await run_agent(agent, user_input) - print(f"Agent: {response}\n") + await run_agent_streaming(agent, user_input) + print() def main() -> None: diff --git a/src/llama_agent_skills/agent.py b/src/llama_agent_skills/agent.py index c997254..2052fd6 100644 --- a/src/llama_agent_skills/agent.py +++ b/src/llama_agent_skills/agent.py @@ -1,9 +1,17 @@ from __future__ import annotations +import sys from typing import Any, Callable import tiktoken -from llama_index.core.agent.workflow import AgentWorkflow +from llama_index.core.agent.workflow import ( + AgentInput, + AgentOutput, + AgentStream, + AgentWorkflow, + ToolCall, + ToolCallResult, +) from llama_index.core.base.llms.types import LLMMetadata from llama_index.llms.openai import OpenAI from llama_index.llms.openai.utils import ( @@ -61,13 +69,17 @@ def build_system_prompt(base_prompt: str, skills: list[Skill]) -> str: if not skills: return base_prompt - parts = [base_prompt, "\n\n---\n\n## Active Skills\n"] - for i, skill in enumerate(skills): - if i > 0: - parts.append("\n---\n") - parts.append( - f"\n### Skill: {skill.name}\n{skill.description}\n\n{skill.body}\n" - ) + parts = [ + base_prompt, + "\n\n---\n\n## Available Skills\n\n", + "You have access to the following skills. " + "When a user's request matches a skill's description, " + "call `activate_skill` with the skill name to load its full instructions. " + "Then follow those instructions to complete the task. " + "Use `read_skill_resource` to load any reference files the skill mentions.\n\n", + ] + for skill in skills: + parts.append(f"- **{skill.name}**: {skill.description}\n") return "".join(parts) @@ -98,3 +110,62 @@ def create_agent( async def run_agent(agent: AgentWorkflow, message: str) -> str: response = await agent.run(user_msg=message) return str(response) + + +async def run_agent_streaming( + agent: AgentWorkflow, + message: str, + *, + verbose: bool = True, +) -> str: + handler = agent.run(user_msg=message) + + async for event in handler.stream_events(): + if isinstance(event, AgentStream): + print(event.delta, end="", flush=True) + elif isinstance(event, AgentInput): + if verbose: + print( + f"\n[Agent: {event.current_agent_name}] Input received", + file=sys.stderr, + flush=True, + ) + elif isinstance(event, ToolCall): + if verbose: + kwargs_preview = str(event.tool_kwargs) + if len(kwargs_preview) > 120: + kwargs_preview = kwargs_preview[:120] + "..." + print( + f"\n>> [Tool] {event.tool_name}({kwargs_preview})", + file=sys.stderr, + flush=True, + ) + elif isinstance(event, ToolCallResult): + if verbose: + output_preview = str(event.tool_output) + output_len = len(output_preview) + if output_len > 200: + output_preview = output_preview[:200] + "..." + print( + f"<< [Tool] {event.tool_name} returned {output_len} chars: {output_preview}", + file=sys.stderr, + flush=True, + ) + elif isinstance(event, AgentOutput): + if verbose: + tool_names = ( + [tc.tool_name for tc in event.tool_calls] + if event.tool_calls + else [] + ) + if tool_names: + print( + f"\n[Agent Output] Tool calls: {tool_names}", + file=sys.stderr, + flush=True, + ) + + print(flush=True) + + result = await handler + return str(result) diff --git a/src/llama_agent_skills/skill.py b/src/llama_agent_skills/skill.py index b64aad9..10abb33 100644 --- a/src/llama_agent_skills/skill.py +++ b/src/llama_agent_skills/skill.py @@ -12,6 +12,8 @@ class SkillLoadError(Exception): _REQUIRED_FIELDS = ("name", "description") +_RESOURCE_DIRS = ("references", "scripts", "assets") + @dataclass(frozen=True) class Skill: @@ -20,6 +22,34 @@ class Skill: body: str metadata: dict = field(default_factory=dict) source_path: Path | None = None + references: list[Path] = field(default_factory=list) + scripts: list[Path] = field(default_factory=list) + assets: list[Path] = field(default_factory=list) + + def list_resources(self) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for category, paths in [ + ("references", self.references), + ("scripts", self.scripts), + ("assets", self.assets), + ]: + if paths: + result[category] = [p.name for p in paths] + return result + + def find_resource(self, filename: str) -> Path | None: + for resource_list in (self.references, self.scripts, self.assets): + for p in resource_list: + if p.name == filename: + return p + return None + + +def _scan_resource_dir(skill_dir: Path, dirname: str) -> list[Path]: + resource_dir = skill_dir / dirname + if not resource_dir.is_dir(): + return [] + return sorted(p for p in resource_dir.iterdir() if p.is_file()) def load_skill(path: Path) -> Skill: @@ -43,10 +73,15 @@ def load_skill(path: Path) -> Skill: f"Invalid SKILL file {path}: missing required field '{field_name}'" ) + skill_dir = path.parent + return Skill( name=post.metadata["name"], description=post.metadata["description"], body=post.content.strip(), metadata=post.metadata.get("metadata", {}), source_path=path, + references=_scan_resource_dir(skill_dir, "references"), + scripts=_scan_resource_dir(skill_dir, "scripts"), + assets=_scan_resource_dir(skill_dir, "assets"), ) diff --git a/src/llama_agent_skills/tools.py b/src/llama_agent_skills/tools.py new file mode 100644 index 0000000..07100f0 --- /dev/null +++ b/src/llama_agent_skills/tools.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from llama_agent_skills.registry import SkillRegistry + + +def create_skill_tools(registry: SkillRegistry) -> list: + def activate_skill(skill_name: str) -> str: + """Activate a skill to get its full instructions. + + Call this when you determine a user's request matches a skill's description. + Returns the complete skill instructions (body) and lists available resources. + + Args: + skill_name: The name of the skill to activate (from the skill list). + """ + skill = registry.get(skill_name) + if skill is None: + available = [s.name for s in registry.list_skills()] + return f"Skill '{skill_name}' not found. Available skills: {available}" + + parts = [skill.body] + + resources = skill.list_resources() + if resources: + parts.append("\n\n---\n\n## Available Resources\n") + parts.append( + "Use `read_skill_resource` to read any of these files when needed:\n" + ) + for category, filenames in resources.items(): + parts.append(f"\n### {category}/") + for fname in filenames: + parts.append(f"- {fname}") + + return "\n".join(parts) + + def read_skill_resource(skill_name: str, filename: str) -> str: + """Read a resource file (reference, script, or asset) from a skill. + + Use this to load supplementary material like templates, schemas, + or reference documentation that a skill mentions. + + Args: + skill_name: The name of the skill that owns the resource. + filename: The filename to read (e.g. 'rfc-template.md', 'excalidraw-schema.md'). + """ + skill = registry.get(skill_name) + if skill is None: + return f"Skill '{skill_name}' not found." + + resource_path = skill.find_resource(filename) + if resource_path is None: + resources = skill.list_resources() + return ( + f"Resource '{filename}' not found in skill '{skill_name}'. " + f"Available resources: {resources}" + ) + + try: + return resource_path.read_text(encoding="utf-8") + except Exception as exc: + return f"Failed to read '{filename}': {exc}" + + return [activate_skill, read_skill_resource]