feat(agent): YAML tool specs as the LLM-facing semantic layer

Introduce a first-class semantic layer for tool descriptions, separated
from Python signatures (which stay the source of truth for types and
required-ness).

New
- alfred/agent/tools/spec.py — ToolSpec / ParameterSpec / ReturnsSpec
  dataclasses with strict YAML validation (ToolSpecError on malformed
  or inconsistent specs). compile_description() builds the rich text
  passed to the LLM as Tool.description, with sections for summary,
  description, when_to_use, when_not_to_use, next_steps, and returns.
  compile_parameter_description() injects the 'why_needed' field next
  to each parameter so the LLM sees the *intent* of each argument.
- alfred/agent/tools/spec_loader.py — discovers tools/specs/*.yaml,
  enforces filename ↔ spec.name match, rejects duplicates.
- alfred/agent/tools/specs/ — one YAML per tool:
    * resolve_season_destination.yaml
    * resolve_episode_destination.yaml
    * resolve_movie_destination.yaml
    * resolve_series_destination.yaml
    * move_to_destination.yaml

Refactor
- alfred/agent/registry.py
    * _create_tool_from_function now takes an optional ToolSpec.
      When provided, the long description + per-parameter descriptions
      come from the spec; types and required-ness still come from the
      Python signature.
    * Cross-validates spec.parameters against the function signature —
      crashes on missing or extra entries.
    * make_tools() loads all specs at startup and hands the right one
      to each tool. Tools without a spec fall back to the old
      docstring-only behaviour, so the 14 not-yet-migrated tools keep
      working unchanged.
    * Adds 'array' and 'object' to the Python→JSON type mapping and
      handles Optional[X] / X | None annotations.

- alfred/agent/tools/filesystem.py
    * Drops the '_tool' suffix on the 4 resolve_* wrappers (option 1:
      alias the use-case imports as _resolve_*). Tool names exposed to
      the LLM now match the underlying use case verbatim.
    * Wrapper docstrings shrink to a one-liner pointing to the YAML
      spec — no more duplicated when_to_use/Args/Returns in Python.

Verified
- make_tools() loads 19 tools (5 with YAML spec, 14 doc-only).
- Compiled descriptions render cleanly with all sections.
This commit is contained in:
2026-05-14 18:06:27 +02:00
parent b5025bb5f8
commit 99c95af64e
9 changed files with 734 additions and 143 deletions
@@ -0,0 +1,93 @@
name: resolve_episode_destination
summary: >
Compute destination paths for a single TV episode file (file move).
description: |
Resolves the target series folder, season subfolder, and full destination
filename for a single-episode release. Returns paths only — does not move
anything. If a series folder with a different name already exists, returns
needs_clarification.
when_to_use: |
Use after analyze_release has identified the release as a single episode
(media_type=tv_show, season AND episode both set). TMDB must already be
queried for the canonical title/year, and optionally the episode title.
when_not_to_use: |
- Season packs (folder containing many episodes): use resolve_season_destination.
- Multi-season packs: use resolve_series_destination.
- Movies: use resolve_movie_destination.
next_steps: |
- On status=ok: call move_to_destination with the source video file and
destination=library_file.
- On status=needs_clarification: present question/options to the user,
then re-call with confirmed_folder set.
- On status=error: surface the message; do not move.
parameters:
release_name:
description: Raw release file name (with extension).
why_needed: |
Drives extraction of quality/source/codec/group, which become part of
the destination filename so each file is self-describing.
example: Oz.S03E01.1080p.WEBRip.x265-KONTRAST.mkv
source_file:
description: Absolute path to the source video file on disk.
why_needed: |
Used to read the source file extension (.mkv, .mp4, .avi…) for the
destination filename — release names don't always carry the extension.
example: /downloads/Oz.S03E01.1080p.WEBRip.x265-KONTRAST/file.mkv
tmdb_title:
description: Canonical show title from TMDB.
why_needed: |
Title prefix for both the series folder and the destination filename;
ensures consistent naming across all episodes of the show.
example: Oz
tmdb_year:
description: Show start year from TMDB.
why_needed: |
Disambiguates remakes/reboots sharing a title; year is part of the
series folder identity.
example: "1997"
tmdb_episode_title:
description: Episode title from TMDB. Optional.
why_needed: |
When present, the destination filename embeds the episode title for
human-readability (e.g. Oz.S01E01.The.Routine...).
example: The Routine
confirmed_folder:
description: Folder name the user picked after needs_clarification.
why_needed: |
Forces the use case to skip detection and use this exact folder name.
example: Oz.1997.1080p.WEBRip.x265-KONTRAST
returns:
ok:
description: Paths resolved; ready to move the episode file.
fields:
series_folder: Absolute path to the series root folder.
season_folder: Absolute path to the season subfolder.
library_file: Absolute path to the destination .mkv file (move target).
series_folder_name: Series folder name for display.
season_folder_name: Season folder name for display.
filename: Destination filename for display.
is_new_series_folder: True if the series folder doesn't exist yet.
needs_clarification:
description: A folder exists with a different name; user must choose.
fields:
question: Human-readable question.
options: List of folder names to pick from.
error:
description: Resolution failed.
fields:
error: Short error code.
message: Human-readable explanation.