diff --git a/openvibe/api.py b/openvibe/api.py index 5e6c543..c5396f3 100644 --- a/openvibe/api.py +++ b/openvibe/api.py @@ -266,9 +266,12 @@ def update_session_config(self, overrides: dict[str, Any]) -> None: # ------------------------------------------------------------------ def _try_command(self, text: str) -> Response | None: - """If *text* is a slash command, execute it and return a Response.""" - from openvibe.commands import (CommandContext, execute, get_command, - is_command) + """If *text* is a registered slash command, execute it and return a Response. + + Returns ``None`` for unrecognised names so that ``_try_skill`` can + handle skill invocations before we fall through to the LLM. + """ + from openvibe.commands import CommandContext, _COMMANDS, execute, get_command, is_command # noqa: PLC2701 if not is_command(text): return None @@ -276,6 +279,10 @@ def _try_command(self, text: str) -> Response | None: if parsed is None: return None name, args = parsed + # Only handle names that are registered as slash commands; unknown + # names may be skill invocations — let _try_skill decide. + if name not in _COMMANDS: + return None ctx = CommandContext(session=self, args=args) result = execute(name, ctx) return Response( @@ -284,6 +291,40 @@ def _try_command(self, text: str) -> Response | None: command_result=result, ) + def _try_skill(self, text: str) -> str | None: + """If *text* is a skill invocation (``/name args``), return the expanded prompt. + + Returns ``None`` when the text is not a skill invocation so that the + caller can fall through to the normal LLM path. + """ + from openvibe.commands import is_command + from openvibe.skill.registry import get_registry + + if not is_command(text): + return None + parts = text[1:].split(None, 1) + name = parts[0].lower() + args = parts[1] if len(parts) > 1 else "" + skill = get_registry().get(name) + if skill is None: + return None + return skill.get_prompt(args) + + def _send_raw( + self, + text: str, + on_token: Callable[[str], None] | None = None, + ) -> Response: + """Send *text* directly to the LLM without command/skill interception. + + Used internally by the :class:`~openvibe.skill.executor.SkillExecutor` + so that retry prompts bypass the skill expansion layer. Assumes the + FSM is already in THINKING state when called from within the skill + executor loop. + """ + self._launch_worker(text, on_token, callback=None) + return self._collect() + def send( self, text: str, @@ -299,16 +340,22 @@ def send( * an error occurs → Response(state=ERROR) Slash commands (``/help``, ``/cost``, etc.) are handled locally and - never reach the LLM. + never reach the LLM. Skill invocations (``/simplify``, ``/debug``, + etc.) are expanded into full LLM prompts before being sent. *on_message(msg_id, role)* — called when a new message is created. *on_tool(msg_id, part_index, state_dict)* — called on tool state changes. """ - # Slash commands bypass the LLM entirely. + # 1. Slash commands bypass the LLM entirely. cmd_response = self._try_command(text) if cmd_response is not None: return cmd_response + # 2. Skill invocations: expand prompt before sending to LLM. + expanded = self._try_skill(text) + if expanded is not None: + text = expanded + with self._lock: if self._state not in (SessionState.IDLE, SessionState.ERROR): raise InvalidStateError( @@ -368,6 +415,11 @@ def send_nowait( callback(cmd_response) return + # Skill invocations: expand prompt before sending to LLM. + expanded = self._try_skill(text) + if expanded is not None: + text = expanded + with self._lock: if self._state not in (SessionState.IDLE, SessionState.ERROR): raise InvalidStateError( @@ -606,6 +658,8 @@ def start(self) -> "OpenVibe": from openvibe.config import load_config from openvibe.db import create_database from openvibe.project import project as _project_module + from openvibe.skill.bundled import init_bundled_skills + from openvibe.skill.loader import load_skills_dir from openvibe.tool.base import create_default_registry if self._config is None: @@ -616,6 +670,9 @@ def start(self) -> "OpenVibe": self._registry = create_default_registry() self._project = _project_module.get_or_create(self._db, self._project_dir) + init_bundled_skills() + load_skills_dir(self._project_dir / "skills") + if self._config.mcp: self._init_mcp() @@ -648,6 +705,8 @@ async def start_async(self) -> "OpenVibe": from openvibe.permission.permission import PermissionService from openvibe.project import project as _project_module from openvibe.session.processor import SessionProcessor + from openvibe.skill.bundled import init_bundled_skills + from openvibe.skill.loader import load_skills_dir from openvibe.tool.base import create_default_registry if self._config is None: @@ -655,6 +714,9 @@ async def start_async(self) -> "OpenVibe": if self._db is None: self._db = create_database() + init_bundled_skills() + load_skills_dir(self._project_dir / "skills") + llm = self._llm or create_default_backend() self._bus = EventBus() self._registry = create_default_registry() diff --git a/openvibe/commands.py b/openvibe/commands.py index 0ef73fa..c8b49c2 100644 --- a/openvibe/commands.py +++ b/openvibe/commands.py @@ -157,7 +157,7 @@ def _config(ctx: CommandContext): # --------------------------------------------------------------------------- -@command("help", "Show available commands") +@command("help", "Show available commands and skills") def cmd_help(ctx: CommandContext) -> CommandResult: lines = ["[bold]Available commands:[/bold]\n"] for name in sorted(_COMMANDS): @@ -166,9 +166,63 @@ def cmd_help(ctx: CommandContext) -> CommandResult: f" [bold cyan]/{name}[/bold cyan] [dim]{entry.description}[/dim]" ) for sub_name, (_, sub_desc) in sorted(entry.subcommands.items()): + lines.append(f" [bold cyan]/{name} {sub_name}[/bold cyan] [dim]{sub_desc}[/dim]") + + # Append skills section + try: + from rich.markup import escape + + from openvibe.skill.registry import get_registry + skills = get_registry().user_invocable() + if skills: + lines.append("\n[bold]Skills[/bold] [dim](route through the LLM):[/dim]\n") + for skill in skills: + aliases = ( + f" [dim]alias: {', '.join(f'/{a}' for a in skill.aliases)}[/dim]" + if skill.aliases + else "" + ) + hint = f" [dim]{escape(skill.argument_hint)}[/dim]" if skill.argument_hint else "" + lines.append( + f" [bold cyan]/{escape(skill.name)}[/bold cyan]{hint}" + f" [dim]{escape(skill.description)}[/dim]{aliases}" + ) + except Exception: + pass + + return CommandResult(output="\n".join(lines)) + + +@command("skills", "List available skills") +def cmd_skills(ctx: CommandContext) -> CommandResult: + """Show all user-invocable skills with metadata.""" + try: + from openvibe.skill.registry import get_registry + except ImportError: + return CommandResult(output="[dim]Skills system not available.[/dim]") + + skills = get_registry().user_invocable() + if not skills: + return CommandResult(output="[dim]No skills registered.[/dim]") + + from rich.markup import escape + + lines = ["[bold]Available skills:[/bold]\n"] + for skill in skills: + lines.append(f"[bold cyan]/{escape(skill.name)}[/bold cyan]") + if skill.aliases: + lines[-1] += f" [dim](aliases: {', '.join(f'/{a}' for a in skill.aliases)})[/dim]" + lines.append(f" [dim]{escape(skill.description)}[/dim]") + if skill.when_to_use: + lines.append(f" [yellow]When to use:[/yellow] [dim]{escape(skill.when_to_use)}[/dim]") + if skill.argument_hint: lines.append( - f" [bold cyan]/{name} {sub_name}[/bold cyan] [dim]{sub_desc}[/dim]" + f" [yellow]Usage:[/yellow] [dim]/{escape(skill.name)} {escape(skill.argument_hint)}[/dim]" ) + if skill.tags: + lines.append(f" [yellow]Tags:[/yellow] [dim]{escape(', '.join(skill.tags))}[/dim]") + lines.append("") + return CommandResult(output="\n".join(lines)) diff --git a/openvibe/skill/__init__.py b/openvibe/skill/__init__.py new file mode 100644 index 0000000..5b3ee7a --- /dev/null +++ b/openvibe/skill/__init__.py @@ -0,0 +1,73 @@ +"""openvibe skills — discoverable prompt-templates that route through the LLM. + +Public surface:: + + from openvibe.skill import ( + SkillDefinition, + SkillResult, + SkillStatus, + CostTier, + SkillValidator, + ValidationResult, + SkillExample, + get_registry, + register_skill, + SkillExecutor, + ExecutionContext, + get_skill_log, + init_skill_log, + load_skills_dir, + SkillLoader, + ) +""" + +from openvibe.skill.base import ( + CostTier, + SkillDefinition, + SkillExample, + SkillResult, + SkillStatus, + SkillValidator, + ValidationResult, +) +from openvibe.skill.executor import ExecutionContext, SkillExecutor +from openvibe.skill.loader import FileSkill, SkillLoader, load_skills_dir +from openvibe.skill.log import get_skill_log, init_skill_log +from openvibe.skill.registry import get_registry, register_skill +from openvibe.skill.verifier import ( + KeywordValidator, + MinLengthValidator, + NoErrorValidator, + NonEmptyValidator, + SkillVerifier, +) + +__all__ = [ + # base + "CostTier", + "SkillDefinition", + "SkillExample", + "SkillResult", + "SkillStatus", + "SkillValidator", + "ValidationResult", + # registry + "get_registry", + "register_skill", + # executor + "ExecutionContext", + "SkillExecutor", + # log + "get_skill_log", + "init_skill_log", + # loader + "FileSkill", + "SkillLoader", + "load_skills_dir", + # validators + "KeywordValidator", + "MinLengthValidator", + "NoErrorValidator", + "NonEmptyValidator", + "SkillVerifier", +] diff --git a/openvibe/skill/base.py b/openvibe/skill/base.py new file mode 100644 index 0000000..fc27bf3 --- /dev/null +++ b/openvibe/skill/base.py @@ -0,0 +1,171 @@ +"""Core skill abstractions. + +A ``SkillDefinition`` is a named, discoverable prompt-template that routes +through the LLM (unlike slash commands which execute locally). Skills carry +rich metadata used for: + +* **Discovery** — capability/tag-based search and ranking. +* **Execution** — retry policy, fallback skill, validator chain. +* **Observability** — cost tier, reliability score updated from the log. + +Bundled skills live in ``openvibe.skill.bundled``. +Custom skills can be registered via :func:`register_skill`. +""" + +from __future__ import annotations + +import abc +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + + +# --------------------------------------------------------------------------- +# Enumerations +# --------------------------------------------------------------------------- + + +class CostTier(StrEnum): + """Expected relative cost of running this skill one time.""" + + LOW = "low" # < 5 tool calls + MEDIUM = "medium" # 5–20 tool calls + HIGH = "high" # 20+ tool calls or expensive tools (web, long bash) + + +class SkillStatus(StrEnum): + SUCCESS = "success" + PARTIAL = "partial" # completed with caveats / validation warnings + RETRIED = "retried" # succeeded after ≥1 retry + FALLBACK = "fallback" # succeeded via fallback_skill + FAILED = "failed" + + +# --------------------------------------------------------------------------- +# Data objects +# --------------------------------------------------------------------------- + + +@dataclass +class SkillExample: + """A concrete example that helps users and the ranking algorithm understand the skill.""" + + input: str # e.g. "the tests are failing after refactor" + description: str # e.g. "diagnose a regression introduced by a refactor" + + +@dataclass +class SkillResult: + """Output of a single skill execution attempt.""" + + skill_name: str + status: SkillStatus + output: str + attempt: int = 1 + elapsed: float = 0.0 + metadata: dict[str, Any] = field(default_factory=dict) + error: str | None = None + + +# --------------------------------------------------------------------------- +# Validators +# --------------------------------------------------------------------------- + + +@dataclass +class ValidationResult: + """Outcome of running a :class:`SkillValidator` against a :class:`SkillResult`.""" + + passed: bool + reason: str = "" + details: dict[str, Any] = field(default_factory=dict) + can_retry: bool = True + retry_hint: str = "" # appended to the retry prompt to guide the next attempt + + +class SkillValidator(abc.ABC): + """Abstract base for validators that inspect a :class:`SkillResult`.""" + + name: str = "validator" + + @abc.abstractmethod + def validate( + self, result: "SkillResult", context: dict[str, Any] + ) -> ValidationResult: + """Return a :class:`ValidationResult`. ``passed=True`` means OK.""" + ... + + +# --------------------------------------------------------------------------- +# SkillDefinition +# --------------------------------------------------------------------------- + + +class SkillDefinition(abc.ABC): + """Abstract base for all skills. + + Subclass this and implement :meth:`get_prompt`. Class-level attributes + provide all metadata; no ``__init__`` override is needed for simple skills. + + Example:: + + class MySkill(SkillDefinition): + name = "myskill" + description = "Does X to Y." + tags = ["x", "y"] + cost_estimate = CostTier.LOW + + def get_prompt(self, args: str) -> str: + return f"Do X to {args or 'this code'}." + """ + + # --- Identity --- + name: str = "" + description: str = "" + aliases: list[str] = [] + + # --- Discovery metadata --- + capabilities: list[str] = [] # e.g. ["code_review", "refactoring"] + input_types: list[str] = [] # e.g. ["code", "file_path", "error_message"] + output_types: list[str] = [] # e.g. ["code_diff", "report", "commit_message"] + constraints: list[str] = [] # e.g. ["requires_git", "read_only"] + tags: list[str] = [] # free-form search tokens + cost_estimate: CostTier = CostTier.MEDIUM + reliability: float = 1.0 # 0.0–1.0; updated in-place by the feedback loop + examples: list[SkillExample] = [] + + # --- UX --- + user_invocable: bool = True # show in /skills list + when_to_use: str = "" # one-line hint shown in /skills + argument_hint: str = "" # e.g. "[focus area]" + + # --- Execution control --- + max_retries: int = 0 # extra attempts beyond the first (0 = no retry) + fallback_skill: str | None = None # skill name to try after all retries exhausted + + # --- Validators --- + validators: list[SkillValidator] = [] + + # ------------------------------------------------------------------ + # Abstract interface + # ------------------------------------------------------------------ + + @abc.abstractmethod + def get_prompt(self, args: str) -> str: + """Return the LLM prompt for this invocation.""" + ... + + def get_retry_prompt(self, args: str, attempt: int, hint: str) -> str: + """Return a modified prompt for retry attempt *attempt* (1-indexed). + + Default: same as :meth:`get_prompt` with an appended hint block. + Override for skill-specific retry strategies. + """ + base = self.get_prompt(args) + if hint: + return ( + f"{base}\n\n" + f"**Previous attempt {attempt - 1} did not satisfy requirements.**\n" + f"Hint for this attempt: {hint}" + ) + return base diff --git a/openvibe/skill/bundled/__init__.py b/openvibe/skill/bundled/__init__.py new file mode 100644 index 0000000..64c41f8 --- /dev/null +++ b/openvibe/skill/bundled/__init__.py @@ -0,0 +1,23 @@ +"""Bundled skills — registered once via :func:`init_bundled_skills`.""" + +from __future__ import annotations + +from openvibe.skill.registry import get_registry + + +def init_bundled_skills() -> None: + """Register all bundled skills in the global registry. + + Called from :meth:`~openvibe.api.OpenVibe.start` and + :meth:`~openvibe.api.OpenVibe.start_async`. + """ + from openvibe.skill.bundled.brainstorm import BrainstormSkill + from openvibe.skill.bundled.draft import DraftSkill + from openvibe.skill.bundled.explain import ExplainSkill + from openvibe.skill.bundled.summarize import SummarizeSkill + + registry = get_registry() + registry.register(SummarizeSkill()) + registry.register(ExplainSkill()) + registry.register(BrainstormSkill()) + registry.register(DraftSkill()) diff --git a/openvibe/skill/bundled/brainstorm.py b/openvibe/skill/bundled/brainstorm.py new file mode 100644 index 0000000..2b63cdc --- /dev/null +++ b/openvibe/skill/bundled/brainstorm.py @@ -0,0 +1,38 @@ +"""``/brainstorm`` — generate a diverse set of ideas around a topic.""" + +from __future__ import annotations + +from openvibe.skill.base import CostTier, SkillDefinition, SkillExample +from openvibe.skill.verifier import KeywordValidator, MinLengthValidator + + +class BrainstormSkill(SkillDefinition): + name = "brainstorm" + description = "Generate a wide set of ideas, options, or approaches for any topic or problem." + aliases = ["ideas", "bs"] + capabilities = ["ideation", "exploration", "creative_thinking"] + input_types = ["topic", "problem", "question", "goal"] + output_types = ["ideas", "list", "options"] + tags = ["ideas", "brainstorm", "options", "explore", "creative"] + cost_estimate = CostTier.LOW + user_invocable = True + when_to_use = "When you need a broad set of options or are stuck on where to start." + argument_hint = "[topic or problem]" + examples = [ + SkillExample("names for a productivity app", "generate product name ideas"), + SkillExample("ways to reduce meeting time", "brainstorm process improvements"), + ] + validators = [MinLengthValidator(100), KeywordValidator(required=["1."])] + + def get_prompt(self, args: str) -> str: + topic = args.strip() or "the topic raised in the current conversation" + return ( + f"Brainstorm ideas for: {topic}\n\n" + "Generate at least 8 distinct ideas. For each idea:\n" + "- Number it (1. 2. 3. …)\n" + "- Give it a short name or headline\n" + "- Add one sentence explaining the core concept\n\n" + "Prioritise variety — include obvious options alongside unconventional ones.\n" + "Do not evaluate or rank the ideas; just list them.\n" + "After the list, briefly note any interesting tensions or trade-offs you see." + ) diff --git a/openvibe/skill/bundled/draft.py b/openvibe/skill/bundled/draft.py new file mode 100644 index 0000000..5500d5d --- /dev/null +++ b/openvibe/skill/bundled/draft.py @@ -0,0 +1,38 @@ +"""``/draft`` — produce a first draft of any written content.""" + +from __future__ import annotations + +from openvibe.skill.base import CostTier, SkillDefinition, SkillExample +from openvibe.skill.verifier import MinLengthValidator, NonEmptyValidator + + +class DraftSkill(SkillDefinition): + name = "draft" + description = "Write a first draft of any content: email, report, proposal, message, or document." + aliases = ["write", "compose"] + capabilities = ["writing", "drafting", "composition"] + input_types = ["topic", "description", "outline", "instructions"] + output_types = ["draft", "document", "email", "report"] + tags = ["write", "draft", "compose", "email", "document", "report"] + cost_estimate = CostTier.LOW + user_invocable = True + when_to_use = "When you need a first version of any written content to work from." + argument_hint = "[what to write and any context]" + examples = [ + SkillExample("an email declining a meeting politely", "draft a short professional email"), + SkillExample("a one-page project proposal for a team dashboard", "draft a proposal"), + ] + validators = [NonEmptyValidator(), MinLengthValidator(100)] + + def get_prompt(self, args: str) -> str: + request = args.strip() or "the content described in the current conversation" + return ( + f"Write a first draft of: {request}\n\n" + "Guidelines:\n" + "- Match the appropriate tone and format for the content type\n" + "- Be clear and direct — avoid filler phrases\n" + "- Use structure (headings, bullets, paragraphs) only when it aids readability\n" + "- Keep the draft appropriately concise; do not pad it\n\n" + "Produce the draft directly, without preamble.\n" + "After the draft, add a brief note on any assumptions you made." + ) diff --git a/openvibe/skill/bundled/explain.py b/openvibe/skill/bundled/explain.py new file mode 100644 index 0000000..aadcd67 --- /dev/null +++ b/openvibe/skill/bundled/explain.py @@ -0,0 +1,38 @@ +"""``/explain`` — explain a concept, decision, or piece of content clearly.""" + +from __future__ import annotations + +from openvibe.skill.base import CostTier, SkillDefinition, SkillExample +from openvibe.skill.verifier import MinLengthValidator, NonEmptyValidator + + +class ExplainSkill(SkillDefinition): + name = "explain" + description = "Explain a concept, decision, document, or anything else in plain language." + aliases = ["eli5", "howdoes"] + capabilities = ["explanation", "clarification", "teaching"] + input_types = ["concept", "text", "question", "document"] + output_types = ["explanation", "analogy", "walkthrough"] + tags = ["explain", "understand", "clarify", "teach", "how", "why"] + cost_estimate = CostTier.LOW + user_invocable = True + when_to_use = "When something is unclear or you need it broken down into simpler terms." + argument_hint = "[concept or question]" + examples = [ + SkillExample("how does OAuth2 work", "explain a technical protocol simply"), + SkillExample("this contract clause", "explain a document section in plain language"), + ] + validators = [NonEmptyValidator(), MinLengthValidator(80)] + + def get_prompt(self, args: str) -> str: + subject = args.strip() or "the most recent topic in the conversation" + return ( + f"Explain: {subject}\n\n" + "Your explanation should:\n" + "- Start with a one-sentence plain-language answer\n" + "- Use a concrete analogy or example where it helps\n" + "- Break down complex parts step by step\n" + "- Avoid unnecessary jargon; define any term that must be used\n" + "- Stay focused — do not over-explain tangential details\n\n" + "Aim for clarity over completeness." + ) diff --git a/openvibe/skill/bundled/summarize.py b/openvibe/skill/bundled/summarize.py new file mode 100644 index 0000000..87d197a --- /dev/null +++ b/openvibe/skill/bundled/summarize.py @@ -0,0 +1,37 @@ +"""``/summarize`` — distil any content into a concise summary.""" + +from __future__ import annotations + +from openvibe.skill.base import CostTier, SkillDefinition, SkillExample +from openvibe.skill.verifier import MinLengthValidator, NonEmptyValidator + + +class SummarizeSkill(SkillDefinition): + name = "summarize" + description = "Distil content, a document, or a topic into a clear, concise summary." + aliases = ["sum", "tldr"] + capabilities = ["summarization", "condensing", "overview"] + input_types = ["text", "document", "topic", "url"] + output_types = ["summary", "bullets", "report"] + tags = ["summary", "overview", "condense", "brief", "tldr"] + cost_estimate = CostTier.LOW + user_invocable = True + when_to_use = "When you need a quick overview of something lengthy or complex." + argument_hint = "[topic, text, or URL to summarize]" + examples = [ + SkillExample("the conversation so far", "recap the current conversation"), + SkillExample("https://example.com/article", "summarize a web page"), + ] + validators = [NonEmptyValidator(), MinLengthValidator(50)] + + def get_prompt(self, args: str) -> str: + subject = args.strip() or "the content or conversation so far" + return ( + f"Summarize: {subject}\n\n" + "Provide a concise summary that:\n" + "- Captures the key points in plain language\n" + "- Uses bullet points when listing multiple items\n" + "- Is no longer than needed — omit filler and repetition\n" + "- Ends with a one-sentence takeaway if helpful\n\n" + "Do not add opinions or information not present in the source." + ) diff --git a/openvibe/skill/executor.py b/openvibe/skill/executor.py new file mode 100644 index 0000000..cd5f7d5 --- /dev/null +++ b/openvibe/skill/executor.py @@ -0,0 +1,211 @@ +"""SkillExecutor — agent-skill loop with retry, fallback, and observability. + +The executor drives one complete skill invocation: + +1. **Select** — caller already resolved the skill via :class:`~openvibe.skill.registry.SkillRegistry`. +2. **Expand** — ``skill.get_prompt(args)`` builds the LLM prompt. +3. **Execute** — ``send_fn(prompt)`` runs the full agent loop and returns text. +4. **Verify** — :class:`~openvibe.skill.verifier.SkillVerifier` checks validators. +5. **Retry** — if verification failed and retries remain, rebuild prompt with hint. +6. **Fallback** — if all retries exhausted and a fallback skill is named, delegate. +7. **Log** — record outcome in :func:`~openvibe.skill.log.get_skill_log`. +8. **Update reliability** — write back ``skill.reliability`` from log. + +The ``send_fn`` callable is intentionally simple: ``(prompt: str) -> str``. +In :class:`~openvibe.api.Session` this is wired to a thin wrapper around the +internal worker so that skills can trigger the full agent + tool loop. + +Example (programmatic use):: + + from openvibe.skill.executor import SkillExecutor, ExecutionContext + from openvibe.skill.registry import get_registry + + registry = get_registry() + executor = SkillExecutor(registry) + + ctx = ExecutionContext( + session_id=session.id, + working_dir="/path/to/project", + send_fn=lambda prompt: session._send_raw(prompt), + ) + + result = executor.run(registry.get("debug"), "TypeError in auth.py", ctx) + print(result.status, result.output) +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable + +from openvibe.skill.base import SkillResult, SkillStatus, ValidationResult +from openvibe.skill.log import SkillLogEntry, get_skill_log +from openvibe.skill.verifier import SkillVerifier + +if TYPE_CHECKING: + from openvibe.skill.base import SkillDefinition + from openvibe.skill.registry import SkillRegistry + + +# --------------------------------------------------------------------------- +# ExecutionContext +# --------------------------------------------------------------------------- + + +@dataclass +class ExecutionContext: + """Everything the executor needs to send prompts and identify the caller. + + ``send_fn`` is the only required callable. It must accept a prompt string + and return the assistant's complete text response. It is allowed to block; + the executor is synchronous. + """ + + session_id: str + working_dir: str + send_fn: Callable[[str], str] + + +# --------------------------------------------------------------------------- +# SkillExecutor +# --------------------------------------------------------------------------- + + +class SkillExecutor: + """Runs one skill invocation through the full agent-skill loop.""" + + def __init__(self, registry: "SkillRegistry") -> None: + self._registry = registry + self._verifier = SkillVerifier() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def run( + self, + skill: "SkillDefinition", + args: str, + ctx: ExecutionContext, + ) -> SkillResult: + """Execute *skill* synchronously and return a :class:`SkillResult`. + + Retry and fallback logic is transparent to the caller. + """ + start = time.monotonic() + last_result: SkillResult | None = None + last_validation: ValidationResult | None = None + + max_attempts = max(1, 1 + skill.max_retries) + + for attempt in range(1, max_attempts + 1): + hint = ( + last_validation.retry_hint + if (last_validation and not last_validation.passed) + else "" + ) + prompt = ( + skill.get_prompt(args) + if attempt == 1 + else skill.get_retry_prompt(args, attempt, hint) + ) + + result = self._call_llm(skill, prompt, attempt, start, ctx) + validation = self._verifier.verify(skill.validators, result) + + if validation.passed: + if attempt > 1: + result.status = SkillStatus.RETRIED + self._finish(skill, args, result) + return result + + last_result = result + last_validation = validation + + if not validation.can_retry or attempt >= max_attempts: + break + + # All attempts exhausted → try fallback + if skill.fallback_skill: + fallback = self._registry.get(skill.fallback_skill) + if fallback and fallback.name != skill.name: + fb_result = self.run(fallback, args, ctx) + fb_result.status = SkillStatus.FALLBACK + fb_result.metadata["original_skill"] = skill.name + fb_result.elapsed = time.monotonic() - start + self._log(skill.name, args, fb_result) + self._update_reliability(skill, success=False) + return fb_result + + # Hard failure + final = last_result or SkillResult( + skill_name=skill.name, + status=SkillStatus.FAILED, + output="", + error="No result produced.", + elapsed=time.monotonic() - start, + ) + final.status = SkillStatus.FAILED + self._finish(skill, args, final, success=False) + return final + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _call_llm( + self, + skill: "SkillDefinition", + prompt: str, + attempt: int, + start: float, + ctx: ExecutionContext, + ) -> SkillResult: + try: + output = ctx.send_fn(prompt) + return SkillResult( + skill_name=skill.name, + status=SkillStatus.SUCCESS, + output=output, + attempt=attempt, + elapsed=time.monotonic() - start, + ) + except Exception as exc: + return SkillResult( + skill_name=skill.name, + status=SkillStatus.FAILED, + output="", + attempt=attempt, + elapsed=time.monotonic() - start, + error=str(exc), + ) + + def _finish( + self, + skill: "SkillDefinition", + args: str, + result: SkillResult, + success: bool = True, + ) -> None: + self._log(skill.name, args, result) + self._update_reliability(skill, success=success) + + def _log(self, skill_name: str, args: str, result: SkillResult) -> None: + get_skill_log().record( + SkillLogEntry( + skill_name=skill_name, + args=args, + status=result.status, + attempt=result.attempt, + elapsed=result.elapsed, + error=result.error, + metadata=result.metadata, + ) + ) + + def _update_reliability( + self, skill: "SkillDefinition", success: bool # noqa: ARG002 (used implicitly via log) + ) -> None: + """Recompute and persist reliability on the live SkillDefinition object.""" + skill.reliability = get_skill_log().compute_reliability(skill.name) diff --git a/openvibe/skill/loader.py b/openvibe/skill/loader.py new file mode 100644 index 0000000..1ea9dc8 --- /dev/null +++ b/openvibe/skill/loader.py @@ -0,0 +1,249 @@ +"""Load skills from a ``skills/`` directory tree. + +Expected layout:: + + skills/ + summarize/ + SKILL.md + my-custom-skill/ + SKILL.md + +Each ``SKILL.md`` file contains optional YAML front-matter followed by the +prompt template. ``{{args}}`` in the template is replaced with the user's +arguments at invocation time:: + + --- + description: Summarize any content concisely. + aliases: [sum, tldr] + tags: [summary, overview] + cost: low + when_to_use: When you need a quick overview of something lengthy. + argument_hint: "[topic or text]" + --- + + Summarize: {{args}} + + Capture the key points in bullet form. End with a one-sentence takeaway. + +Front-matter keys (all optional) +--------------------------------- +* ``description`` — shown in ``/skills`` +* ``aliases`` — list of shorthand names +* ``tags`` — list of search tokens +* ``capabilities`` — list of capability labels +* ``input_types`` / ``output_types`` — list of type labels +* ``cost`` — ``low`` | ``medium`` | ``high`` (default ``medium``) +* ``when_to_use`` — one-line hint shown in ``/skills`` +* ``argument_hint`` — e.g. ``[topic]`` +* ``max_retries`` — int (default ``0``) +* ``fallback`` — name of another skill to try on failure +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from openvibe.skill.base import CostTier, SkillDefinition +from openvibe.skill.registry import SkillRegistry, get_registry + +_FRONTMATTER_RE = re.compile(r"^\s*---\s*\n(.*?)\n---\s*\n", re.DOTALL) + + +# --------------------------------------------------------------------------- +# FileSkill — a SkillDefinition backed by a SKILL.md file +# --------------------------------------------------------------------------- + + +class FileSkill(SkillDefinition): + """A skill loaded from a ``SKILL.md`` file.""" + + def __init__( + self, + name: str, + prompt_template: str, + meta: dict[str, Any], + ) -> None: + self.name = name + self.description = str(meta.get("description", "")) + self.aliases = list(meta.get("aliases") or []) + self.tags = list(meta.get("tags") or []) + self.capabilities = list(meta.get("capabilities") or []) + self.input_types = list(meta.get("input_types") or []) + self.output_types = list(meta.get("output_types") or []) + self.when_to_use = str(meta.get("when_to_use", "")) + self.argument_hint = str(meta.get("argument_hint", "")) + self.max_retries = int(meta.get("max_retries", 0)) + self.fallback_skill = meta.get("fallback") or None + self.user_invocable = True + self._template = prompt_template.strip() + + # cost + cost_raw = str(meta.get("cost", "medium")).lower() + self.cost_estimate = CostTier(cost_raw) if cost_raw in CostTier._value2member_map_ else CostTier.MEDIUM # type: ignore[attr-defined] + + def get_prompt(self, args: str) -> str: + return self._template.replace("{{args}}", args).strip() + + +# --------------------------------------------------------------------------- +# Loader +# --------------------------------------------------------------------------- + + +class SkillLoader: + """Discovers and loads skills from a directory tree. + + Only the ``skill-name/SKILL.md`` layout is supported. Directories + without a ``SKILL.md`` are silently skipped. + """ + + SKILL_FILE = "SKILL.md" + + def __init__(self, registry: SkillRegistry | None = None) -> None: + self._registry = registry or get_registry() + + def load(self, skills_dir: Path | str) -> list[FileSkill]: + """Load all skills found under *skills_dir* and register them. + + Returns the list of successfully loaded :class:`FileSkill` instances. + Errors in individual files are logged but do not abort the load. + """ + skills_dir = Path(skills_dir) + if not skills_dir.is_dir(): + return [] + + loaded: list[FileSkill] = [] + for entry in sorted(skills_dir.iterdir()): + if not entry.is_dir(): + continue + skill_file = entry / self.SKILL_FILE + if not skill_file.is_file(): + continue + skill = self._load_file(entry.name, skill_file) + if skill is not None: + self._registry.register(skill) + loaded.append(skill) + + return loaded + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _load_file(self, dir_name: str, skill_file: Path) -> "FileSkill | None": + try: + raw = skill_file.read_text(encoding="utf-8") + except OSError as exc: + import logging + logging.getLogger(__name__).warning("Could not read %s: %s", skill_file, exc) + return None + + meta, template = _parse_skill_md(raw) + + # Derive skill name: front-matter ``name`` overrides directory name + name = str(meta.pop("name", dir_name)).lower().replace(" ", "-") + + if not template.strip(): + import logging + logging.getLogger(__name__).warning( + "Skipping %s: no prompt template found (body is empty)", skill_file + ) + return None + + return FileSkill(name=name, prompt_template=template, meta=meta) + + +# --------------------------------------------------------------------------- +# Parsing helpers +# --------------------------------------------------------------------------- + + +def _parse_skill_md(text: str) -> tuple[dict[str, Any], str]: + """Split a SKILL.md into ``(front_matter_dict, prompt_template)``.""" + m = _FRONTMATTER_RE.match(text) + if not m: + return {}, text + + meta = _parse_yaml_lite(m.group(1)) + template = text[m.end():] + return meta, template + + +def _parse_yaml_lite(text: str) -> dict[str, Any]: + """Minimal YAML parser that handles the subset used in SKILL.md front-matter. + + Supports: + * ``key: scalar value`` + * ``key: [item1, item2]`` (inline list) + * Block lists:: + + key: + - item1 + - item2 + + Falls back to the ``yaml`` package when available for full YAML support. + """ + try: + import yaml # type: ignore[import] + return yaml.safe_load(text) or {} + except ImportError: + pass + + result: dict[str, Any] = {} + lines = text.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + if not line.strip() or line.lstrip().startswith("#"): + i += 1 + continue + + m = re.match(r"^(\w[\w_-]*):\s*(.*)", line) + if not m: + i += 1 + continue + + key = m.group(1) + value_str = m.group(2).strip() + + if value_str.startswith("[") and value_str.endswith("]"): + # Inline list: [a, b, c] + inner = value_str[1:-1] + result[key] = [v.strip().strip("\"'") for v in inner.split(",") if v.strip()] + elif not value_str: + # Potential block list + items: list[str] = [] + i += 1 + while i < len(lines) and lines[i].lstrip().startswith("-"): + items.append(lines[i].lstrip().lstrip("-").strip().strip("\"'")) + i += 1 + result[key] = items + continue + else: + # Scalar — strip surrounding quotes + result[key] = value_str.strip("\"'") + + i += 1 + + return result + + +# --------------------------------------------------------------------------- +# Convenience function +# --------------------------------------------------------------------------- + + +def load_skills_dir( + skills_dir: Path | str, + registry: SkillRegistry | None = None, +) -> list[FileSkill]: + """Load skills from *skills_dir* into *registry* (defaults to global registry). + + This is the main entry point for external callers:: + + from openvibe.skill.loader import load_skills_dir + load_skills_dir(Path("./skills")) + """ + return SkillLoader(registry).load(skills_dir) diff --git a/openvibe/skill/log.py b/openvibe/skill/log.py new file mode 100644 index 0000000..b7ceedd --- /dev/null +++ b/openvibe/skill/log.py @@ -0,0 +1,168 @@ +"""Skill execution logging — observability and feedback loop. + +Every skill execution is appended to an in-memory ring buffer and optionally +to a JSONL file on disk. :meth:`SkillLog.compute_reliability` reads the +recent window to produce the 0–1 score that is written back to +``SkillDefinition.reliability``, closing the feedback loop. + +Usage:: + + from openvibe.skill.log import get_skill_log + + log = get_skill_log() + print(log.stats("simplify")) + # {'total': 12, 'success_rate': 0.916, 'failure_rate': 0.083, ...} +""" + +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from openvibe.skill.base import SkillStatus + + +# --------------------------------------------------------------------------- +# Log entry +# --------------------------------------------------------------------------- + + +@dataclass +class SkillLogEntry: + skill_name: str + args: str + status: str # SkillStatus value + attempt: int # which attempt succeeded / finally failed + elapsed: float # total wall-clock seconds for all attempts + timestamp: float = field(default_factory=time.time) + error: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# SkillLog +# --------------------------------------------------------------------------- + + +class SkillLog: + """Thread-safe in-memory skill log with optional JSONL persistence. + + The ring buffer caps at :attr:`MAX_ENTRIES_IN_MEMORY` so long-running + processes don't grow unbounded. + """ + + MAX_ENTRIES_IN_MEMORY: int = 500 + # How many recent entries to consider for reliability scoring. + RELIABILITY_WINDOW: int = 100 + + def __init__(self, log_path: Path | None = None) -> None: + self._path = log_path + self._entries: list[SkillLogEntry] = [] + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + + def record(self, entry: SkillLogEntry) -> None: + """Append *entry* to the log.""" + self._entries.append(entry) + if len(self._entries) > self.MAX_ENTRIES_IN_MEMORY: + self._entries = self._entries[-self.MAX_ENTRIES_IN_MEMORY :] + if self._path: + self._append_to_file(entry) + + # ------------------------------------------------------------------ + # Read / aggregate + # ------------------------------------------------------------------ + + def entries(self, skill_name: str | None = None) -> list[SkillLogEntry]: + """Return entries, optionally filtered to a single skill.""" + if skill_name is None: + return list(self._entries) + return [e for e in self._entries if e.skill_name == skill_name] + + def stats(self, skill_name: str | None = None) -> dict[str, Any]: + """Return aggregate statistics over logged entries. + + Keys: ``total``, ``success_rate``, ``failure_rate``, + ``partial_rate``, ``retry_rate``, ``avg_elapsed_s``. + """ + entries = self.entries(skill_name) + if not entries: + return {} + + total = len(entries) + successes = sum( + 1 + for e in entries + if e.status in (SkillStatus.SUCCESS, SkillStatus.RETRIED, SkillStatus.FALLBACK) + ) + partials = sum(1 for e in entries if e.status == SkillStatus.PARTIAL) + failures = sum(1 for e in entries if e.status == SkillStatus.FAILED) + retried = sum(1 for e in entries if e.attempt > 1) + avg_elapsed = sum(e.elapsed for e in entries) / total + + return { + "total": total, + "success_rate": round(successes / total, 4), + "partial_rate": round(partials / total, 4), + "failure_rate": round(failures / total, 4), + "retry_rate": round(retried / total, 4), + "avg_elapsed_s": round(avg_elapsed, 3), + } + + def compute_reliability(self, skill_name: str) -> float: + """Return a 0–1 reliability score from the most recent executions. + + Counts SUCCESS, RETRIED, PARTIAL, and FALLBACK as "good" outcomes. + Returns ``1.0`` when there is no history (benefit of the doubt). + """ + window = [ + e + for e in self._entries[-self.RELIABILITY_WINDOW :] + if e.skill_name == skill_name + ] + if not window: + return 1.0 + good_statuses = { + SkillStatus.SUCCESS, + SkillStatus.RETRIED, + SkillStatus.PARTIAL, + SkillStatus.FALLBACK, + } + good = sum(1 for e in window if e.status in good_statuses) + return round(good / len(window), 4) + + # ------------------------------------------------------------------ + # Persistence + # ------------------------------------------------------------------ + + def _append_to_file(self, entry: SkillLogEntry) -> None: + try: + with self._path.open("a", encoding="utf-8") as f: # type: ignore[union-attr] + f.write(json.dumps(asdict(entry)) + "\n") + except OSError: + pass # log failures are non-fatal + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + + +_global_log: SkillLog = SkillLog() + + +def get_skill_log() -> SkillLog: + """Return the global :class:`SkillLog` singleton.""" + return _global_log + + +def init_skill_log(log_path: Path | None = None) -> SkillLog: + """(Re-)initialise the global log, optionally attaching a JSONL file.""" + global _global_log + _global_log = SkillLog(log_path=log_path) + return _global_log diff --git a/openvibe/skill/registry.py b/openvibe/skill/registry.py new file mode 100644 index 0000000..55766ec --- /dev/null +++ b/openvibe/skill/registry.py @@ -0,0 +1,141 @@ +"""SkillRegistry — registration, indexing, search, and reliability-weighted ranking. + +Skills are indexed by name and alias. :meth:`SkillRegistry.search` supports +keyword queries over name, description, tags, capabilities, and +input/output types, with a reliability multiplier that gradually demotes +consistently-failing skills. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from openvibe.skill.base import SkillDefinition + + +class SkillRegistry: + """Global registry of all :class:`~openvibe.skill.base.SkillDefinition` instances.""" + + def __init__(self) -> None: + self._by_name: dict[str, "SkillDefinition"] = {} + self._by_alias: dict[str, str] = {} # alias → canonical name + + # ------------------------------------------------------------------ + # Registration + # ------------------------------------------------------------------ + + def register(self, skill: "SkillDefinition") -> None: + """Register *skill*. Overwrites any existing skill with the same name.""" + self._by_name[skill.name] = skill + for alias in skill.aliases: + self._by_alias[alias.lower()] = skill.name + + # ------------------------------------------------------------------ + # Retrieval + # ------------------------------------------------------------------ + + def get(self, name: str) -> "SkillDefinition | None": + """Look up a skill by name or alias (case-insensitive).""" + name_lower = name.lower() + skill = self._by_name.get(name_lower) or self._by_name.get(name) + if skill: + return skill + canonical = self._by_alias.get(name_lower) + if canonical: + return self._by_name.get(canonical) + return None + + def all(self) -> list["SkillDefinition"]: + """Return all registered skills in registration order.""" + return list(self._by_name.values()) + + def user_invocable(self) -> list["SkillDefinition"]: + """Return skills that should appear in ``/skills`` output.""" + return [s for s in self._by_name.values() if s.user_invocable] + + # ------------------------------------------------------------------ + # Search & ranking + # ------------------------------------------------------------------ + + def search(self, query: str, top_k: int = 5) -> list[tuple["SkillDefinition", float]]: + """Return up to *top_k* skills ranked by relevance to *query*. + + Scoring: + * Exact name / alias match → +10 / +8 + * Keyword overlap with description → +1 per word + * Tag / capability / type match → +2 per token + * Reliability multiplier: ``0.5 + 0.5 * skill.reliability`` + (a skill with reliability=0 is scored at 50 % of its raw score) + """ + query_tokens = _tokenise(query) + results: list[tuple["SkillDefinition", float]] = [] + + for skill in self._by_name.values(): + score = self._score(skill, query_tokens) + if score > 0: + results.append((skill, score)) + + results.sort(key=lambda x: x[1], reverse=True) + return results[:top_k] + + def find_best(self, query: str) -> "SkillDefinition | None": + """Return the single highest-ranked skill for *query*, or ``None``.""" + results = self.search(query, top_k=1) + return results[0][0] if results else None + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _score(self, skill: "SkillDefinition", query_tokens: set[str]) -> float: + score = 0.0 + + # Exact name / alias hit + if skill.name.lower() in query_tokens: + score += 10.0 + for alias in skill.aliases: + if alias.lower() in query_tokens: + score += 8.0 + + # Description overlap + desc_tokens = _tokenise(skill.description) + score += len(query_tokens & desc_tokens) * 1.0 + + # Tags + capabilities + input/output types + meta_tokens = _tokenise( + " ".join(skill.tags + skill.capabilities + skill.input_types + skill.output_types) + ) + score += len(query_tokens & meta_tokens) * 2.0 + + # when_to_use overlap + use_tokens = _tokenise(skill.when_to_use) + score += len(query_tokens & use_tokens) * 1.5 + + # Reliability weight: unreliable skills are demoted but not excluded + score *= 0.5 + 0.5 * max(0.0, min(1.0, skill.reliability)) + + return score + + +# --------------------------------------------------------------------------- +# Module-level singleton + helpers +# --------------------------------------------------------------------------- + + +_registry: SkillRegistry = SkillRegistry() + + +def get_registry() -> SkillRegistry: + """Return the global :class:`SkillRegistry` singleton.""" + return _registry + + +def register_skill(skill: "SkillDefinition") -> None: + """Register *skill* in the global registry.""" + _registry.register(skill) + + +def _tokenise(text: str) -> set[str]: + return set(re.findall(r"[a-z0-9]+", text.lower())) diff --git a/openvibe/skill/verifier.py b/openvibe/skill/verifier.py new file mode 100644 index 0000000..23251d7 --- /dev/null +++ b/openvibe/skill/verifier.py @@ -0,0 +1,139 @@ +"""Skill execution verification — built-in validators and the SkillVerifier. + +Built-in validators +------------------- +* :class:`NonEmptyValidator` — output must be non-empty. +* :class:`NoErrorValidator` — result must not carry an error flag. +* :class:`KeywordValidator` — output must contain required / not forbidden words. +* :class:`MinLengthValidator` — output must exceed a character threshold. + +Custom validators +----------------- +Subclass :class:`~openvibe.skill.base.SkillValidator` and add instances to +``SkillDefinition.validators``. The verifier short-circuits on the first +failure, returning its :class:`~openvibe.skill.base.ValidationResult`. +""" + +from __future__ import annotations + +from typing import Any + +from openvibe.skill.base import SkillResult, SkillValidator, ValidationResult + + +# --------------------------------------------------------------------------- +# Built-in validators +# --------------------------------------------------------------------------- + + +class NonEmptyValidator(SkillValidator): + """Fails when the skill output is empty or whitespace-only.""" + + name = "non_empty" + + def validate(self, result: SkillResult, context: dict[str, Any]) -> ValidationResult: + if not result.output.strip(): + return ValidationResult( + passed=False, + reason="Skill produced no output.", + can_retry=True, + retry_hint="Produce a complete, non-empty response.", + ) + return ValidationResult(passed=True) + + +class NoErrorValidator(SkillValidator): + """Fails when the result carries an error.""" + + name = "no_error" + + def validate(self, result: SkillResult, context: dict[str, Any]) -> ValidationResult: + if result.error: + return ValidationResult( + passed=False, + reason=f"Skill returned an error: {result.error}", + can_retry=True, + retry_hint="The previous attempt raised an error. Try a different approach.", + ) + return ValidationResult(passed=True) + + +class KeywordValidator(SkillValidator): + """Ensures the output contains required phrases and lacks forbidden ones.""" + + name = "keyword" + + def __init__( + self, + required: list[str] | None = None, + forbidden: list[str] | None = None, + ) -> None: + self._required = [k.lower() for k in (required or [])] + self._forbidden = [k.lower() for k in (forbidden or [])] + + def validate(self, result: SkillResult, context: dict[str, Any]) -> ValidationResult: + lower = result.output.lower() + for kw in self._required: + if kw not in lower: + return ValidationResult( + passed=False, + reason=f"Required phrase '{kw}' not found in output.", + can_retry=True, + retry_hint=f"Make sure the response includes '{kw}'.", + ) + for kw in self._forbidden: + if kw in lower: + return ValidationResult( + passed=False, + reason=f"Forbidden phrase '{kw}' found in output.", + can_retry=True, + retry_hint=f"Do not include '{kw}' in the response.", + ) + return ValidationResult(passed=True) + + +class MinLengthValidator(SkillValidator): + """Fails when the output is shorter than *min_chars* characters.""" + + name = "min_length" + + def __init__(self, min_chars: int = 50) -> None: + self._min = min_chars + + def validate(self, result: SkillResult, context: dict[str, Any]) -> ValidationResult: + length = len(result.output.strip()) + if length < self._min: + return ValidationResult( + passed=False, + reason=f"Output too short ({length} chars, minimum {self._min}).", + can_retry=True, + retry_hint=f"Provide a more complete response (at least {self._min} characters).", + ) + return ValidationResult(passed=True) + + +# --------------------------------------------------------------------------- +# SkillVerifier +# --------------------------------------------------------------------------- + + +class SkillVerifier: + """Runs a skill's validator chain and returns the first failure, or a pass.""" + + def verify( + self, + validators: list[SkillValidator], + result: SkillResult, + context: dict[str, Any] | None = None, + ) -> ValidationResult: + """Run all *validators* in order; short-circuit on first failure. + + Returns :class:`~openvibe.skill.base.ValidationResult` with + ``passed=True`` when all validators succeed (or the list is empty). + """ + ctx = context or {} + for validator in validators: + vr = validator.validate(result, ctx) + if not vr.passed: + return vr + return ValidationResult(passed=True, reason="All validators passed.") diff --git a/openvibe/tool/web_fetch.py b/openvibe/tool/web_fetch.py index f43e9d0..bfd35c3 100644 --- a/openvibe/tool/web_fetch.py +++ b/openvibe/tool/web_fetch.py @@ -64,7 +64,7 @@ async def execute( async with httpx.AsyncClient(follow_redirects=True, timeout=30) as client: response = await client.get( params.url, - headers={"User-Agent: openvibe/0.1 (AI coding agent))"}, + headers={"User-Agent": "openvibe/0.1 (AI coding agent)"}, ) response.raise_for_status() except httpx.TimeoutException: diff --git a/openvibe/tui/screens/session.py b/openvibe/tui/screens/session.py index c63c092..e24b8bf 100644 --- a/openvibe/tui/screens/session.py +++ b/openvibe/tui/screens/session.py @@ -221,11 +221,17 @@ async def handle_submitted(self, event: InputBar.Submitted) -> None: # Slash commands — handled at the API level, but we intercept the # result here to avoid freezing the UI / showing a spinner. - from openvibe.commands import is_command + # Skill invocations (/skillname args) look like commands but go to + # the LLM; route them through _start_turn instead. + from openvibe.commands import _COMMANDS, is_command # noqa: PLC2701 if is_command(event.text): - await self._handle_command(event.text) - return + parts = event.text[1:].split(None, 1) + name = parts[0].lower() if parts else "" + if name in _COMMANDS: + await self._handle_command(event.text) + return + # Not a registered command — fall through to LLM path (skill). input_bar = self.query_one(InputBar) input_bar.record_submission(event.text) @@ -295,7 +301,7 @@ async def _handle_command(self, text: str) -> None: reply_msg.id, str(MessageRole.ASSISTANT), ) - result_widget.append_text(result.output) + result_widget.set_markup(result.output) # ------------------------------------------------------------------ # Permission handling diff --git a/openvibe/tui/widgets/messages.py b/openvibe/tui/widgets/messages.py index 82c0eec..5a5f3bb 100644 --- a/openvibe/tui/widgets/messages.py +++ b/openvibe/tui/widgets/messages.py @@ -234,6 +234,13 @@ def append_text(self, content: str) -> None: self._text += safe widget.update(self._text) + def set_markup(self, content: str) -> None: + """Display pre-rendered Rich markup directly, bypassing markdown processing.""" + widget = self._simple_widget() + widget.remove_class("hidden-text") + self._text = content + widget.update(content) + def replace_text(self, content: str) -> None: self._text = content if self._role == "assistant":