feat(agent): workflow-scoped tool catalog + start/end_workflow meta-tools

Introduce a scope-aware agent so the LLM never sees the full 21-tool
catalog at once. The system prompt now describes either:
  - idle mode: core noyau (5 tools: set_language, set_path_for_folder,
    list_folder, start_workflow, end_workflow) + a list of available
    workflows with their goals;
  - active mode: the noyau plus the tools declared by the active
    workflow's YAML, with the step plan inlined into the prompt.

Pieces:
- alfred/agent/tools/workflow.py: start_workflow / end_workflow tools
  (with YAML specs under tools/specs/) that drive memory.stm.workflow.
- alfred/agent/prompt.py: CORE_TOOLS constant, visible_tool_names(),
  filtered build_tools_spec() / _format_tools_description(), and a new
  _format_workflow_scope() section in the system prompt.
- alfred/agent/agent.py: WorkflowLoader wired into Agent, defensive
  out-of-scope check in _execute_tool_call.
- alfred/agent/registry.py: registers the two new meta-tools (21 total,
  7 with YAML spec).
- workflows/media.organize_media.yaml: tools/steps list refreshed to
  match the current resolver split (analyze_release, probe_media,
  resolve_*_destination, move_to_destination).
This commit is contained in:
2026-05-14 21:07:36 +02:00
parent 97adfbda45
commit 74a52ba6a3
7 changed files with 338 additions and 23 deletions
+16 -2
View File
@@ -10,6 +10,7 @@ from alfred.settings import settings
from .prompt import PromptBuilder from .prompt import PromptBuilder
from .registry import Tool, make_tools from .registry import Tool, make_tools
from .workflows import WorkflowLoader
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,8 +34,8 @@ class Agent:
self.settings = settings self.settings = settings
self.llm = llm self.llm = llm
self.tools: dict[str, Tool] = make_tools(settings) self.tools: dict[str, Tool] = make_tools(settings)
self.prompt_builder = PromptBuilder(self.tools) self.workflow_loader = WorkflowLoader()
self.settings = settings self.prompt_builder = PromptBuilder(self.tools, self.workflow_loader)
self.max_tool_iterations = max_tool_iterations self.max_tool_iterations = max_tool_iterations
def step(self, user_input: str) -> str: def step(self, user_input: str) -> str:
@@ -168,6 +169,19 @@ class Agent:
"available_tools": available, "available_tools": available,
} }
# Defensive: reject calls to tools that are not currently in scope.
visible = set(self.prompt_builder.visible_tool_names())
if tool_name not in visible:
return {
"error": "tool_out_of_scope",
"message": (
f"Tool '{tool_name}' is not available in the current "
"workflow scope. Call end_workflow first or start the "
"appropriate workflow."
),
"available_tools": sorted(visible),
}
tool = self.tools[tool_name] tool = self.tools[tool_name]
# Execute tool # Execute tool
+96 -6
View File
@@ -8,15 +8,56 @@ from alfred.infrastructure.persistence.memory import MemoryRegistry
from .expressions import build_expressions_context from .expressions import build_expressions_context
from .registry import Tool from .registry import Tool
from .workflows import WorkflowLoader
# Tools that are always available, regardless of workflow scope.
# Kept small on purpose — the noyau is what the agent uses to either
# answer trivially or pivot into a workflow.
CORE_TOOLS: tuple[str, ...] = (
"set_language",
"set_path_for_folder",
"list_folder",
"start_workflow",
"end_workflow",
)
class PromptBuilder: class PromptBuilder:
"""Builds system prompts for the agent with memory context.""" """Builds system prompts for the agent with memory context."""
def __init__(self, tools: dict[str, Tool]): def __init__(
self,
tools: dict[str, Tool],
workflow_loader: WorkflowLoader | None = None,
):
self.tools = tools self.tools = tools
self.workflow_loader = workflow_loader or WorkflowLoader()
self._memory_registry = MemoryRegistry() self._memory_registry = MemoryRegistry()
def _active_workflow(self, memory) -> dict | None:
"""Return the YAML definition of the active workflow, or None."""
current = memory.stm.workflow.current
if current is None:
return None
return self.workflow_loader.get(current.get("type"))
def visible_tool_names(self) -> list[str]:
"""
Return the names of the tools currently in scope.
- Idle (no workflow): core noyau only. The LLM enters a workflow
via start_workflow to access more tools.
- Workflow active: core noyau + the workflow's declared tools.
"""
memory = get_memory()
visible = set(CORE_TOOLS)
workflow = self._active_workflow(memory)
if workflow is not None:
for name in workflow.get("tools", []):
visible.add(name)
# Only return tools that actually exist in the registry.
return [name for name in self.tools if name in visible]
def _format_identity(self, memory) -> str: def _format_identity(self, memory) -> str:
"""Build Alfred's identity and personality section.""" """Build Alfred's identity and personality section."""
username = memory.stm.get_entity("username") username = memory.stm.get_entity("username")
@@ -58,9 +99,12 @@ EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta r
{expressions_block}""" {expressions_block}"""
def build_tools_spec(self) -> list[dict[str, Any]]: def build_tools_spec(self) -> list[dict[str, Any]]:
"""Build the tool specification for the LLM API.""" """Build the tool specification for the LLM API (scope-filtered)."""
visible = set(self.visible_tool_names())
tool_specs = [] tool_specs = []
for tool in self.tools.values(): for tool in self.tools.values():
if tool.name not in visible:
continue
spec = { spec = {
"type": "function", "type": "function",
"function": { "function": {
@@ -73,15 +117,57 @@ EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta r
return tool_specs return tool_specs
def _format_tools_description(self) -> str: def _format_tools_description(self) -> str:
"""Format tools with their descriptions and parameters.""" """Format the currently-visible tools with description + params."""
if not self.tools: visible = set(self.visible_tool_names())
visible_tools = [t for t in self.tools.values() if t.name in visible]
if not visible_tools:
return "" return ""
return "\n".join( return "\n".join(
f"- {tool.name}: {tool.description}\n" f"- {tool.name}: {tool.description}\n"
f" Parameters: {json.dumps(tool.parameters, ensure_ascii=False)}" f" Parameters: {json.dumps(tool.parameters, ensure_ascii=False)}"
for tool in self.tools.values() for tool in visible_tools
) )
def _format_workflow_scope(self, memory) -> str:
"""Describe the current workflow scope so the LLM has a plan."""
workflow = self._active_workflow(memory)
if workflow is None:
available = self.workflow_loader.names()
if not available:
return ""
lines = ["WORKFLOW SCOPE: idle (broad catalog narrowed to core noyau)."]
lines.append(
" Call start_workflow(workflow_name, params) to enter a scope."
)
lines.append(" Available workflows:")
for name in available:
wf = self.workflow_loader.get(name) or {}
desc = (wf.get("description") or "").strip().splitlines()
summary = desc[0] if desc else ""
lines.append(f" - {name}: {summary}")
return "\n".join(lines)
current = memory.stm.workflow.current or {}
lines = [
f"WORKFLOW SCOPE: active — {current.get('type')} "
f"(stage: {current.get('stage')})",
]
target = current.get("target")
if target:
lines.append(f" Params: {target}")
wf_desc = (workflow.get("description") or "").strip()
if wf_desc:
lines.append(f" Goal: {wf_desc}")
steps = workflow.get("steps", [])
if steps:
lines.append(" Steps:")
for step in steps:
step_id = step.get("id", "?")
step_tool = step.get("tool") or ("ask_user" if step.get("ask_user") else "")
lines.append(f" - {step_id} ({step_tool})")
lines.append(" Call end_workflow(reason) when done, cancelled, or off-topic.")
return "\n".join(lines)
def _format_episodic_context(self, memory) -> str: def _format_episodic_context(self, memory) -> str:
"""Format episodic memory context for the prompt.""" """Format episodic memory context for the prompt."""
lines = [] lines = []
@@ -213,7 +299,10 @@ EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta r
# Memory schema # Memory schema
memory_schema = self._format_memory_schema() memory_schema = self._format_memory_schema()
# Available tools # Workflow scope (active workflow plan or list of options)
workflow_section = self._format_workflow_scope(memory)
# Available tools (already filtered by scope)
tools_desc = self._format_tools_description() tools_desc = self._format_tools_description()
tools_section = f"\nOUTILS DISPONIBLES:\n{tools_desc}" if tools_desc else "" tools_section = f"\nOUTILS DISPONIBLES:\n{tools_desc}" if tools_desc else ""
@@ -233,6 +322,7 @@ RÈGLES:
stm_context, stm_context,
episodic_context, episodic_context,
memory_schema, memory_schema,
workflow_section,
tools_section, tools_section,
rules, rules,
] ]
+3
View File
@@ -129,6 +129,7 @@ def make_tools(settings) -> dict[str, Tool]:
from .tools import api as api_tools # noqa: PLC0415 from .tools import api as api_tools # noqa: PLC0415
from .tools import filesystem as fs_tools # noqa: PLC0415 from .tools import filesystem as fs_tools # noqa: PLC0415
from .tools import language as lang_tools # noqa: PLC0415 from .tools import language as lang_tools # noqa: PLC0415
from .tools import workflow as wf_tools # noqa: PLC0415
tool_functions = [ tool_functions = [
fs_tools.set_path_for_folder, fs_tools.set_path_for_folder,
@@ -150,6 +151,8 @@ def make_tools(settings) -> dict[str, Tool]:
api_tools.add_torrent_to_qbittorrent, api_tools.add_torrent_to_qbittorrent,
api_tools.get_torrent_by_index, api_tools.get_torrent_by_index,
lang_tools.set_language, lang_tools.set_language,
wf_tools.start_workflow,
wf_tools.end_workflow,
] ]
specs = load_tool_specs() specs = load_tool_specs()
@@ -0,0 +1,48 @@
name: end_workflow
summary: >
Leave the current workflow scope and return to the broad-catalog mode.
description: |
Clears the active workflow from STM. After this call the visible tool
catalog returns to the core noyau plus start_workflow, so the agent is
ready to handle a different request.
when_to_use: |
- When all the workflow's steps have completed successfully.
- When the user explicitly cancels the current task.
- When the user changes subject mid-conversation and the active
workflow is no longer relevant.
- When an unrecoverable error makes continuing pointless — explain
in 'reason'.
when_not_to_use: |
- Do not call when there is no active workflow — it will return an
error. Just call start_workflow for the new request instead.
- Do not call mid-step just to "free up tools"; finish the step
or fail it explicitly first.
next_steps: |
- After ending, you can either call start_workflow for a new task or
answer the user directly from the broad catalog.
parameters:
reason:
description: Short reason for ending — completed, cancelled, changed_subject, error, ...
why_needed: |
Recorded in episodic memory for debugging and future audits. A
structured short string is more useful than a long sentence.
example: completed
returns:
ok:
description: Workflow ended; catalog is back to the broad noyau.
fields:
workflow: Name of the workflow that just ended.
reason: The reason that was passed in.
error:
description: Could not end — typically because nothing was active.
fields:
error: Short error code (no_active_workflow).
message: Human-readable explanation.
@@ -0,0 +1,64 @@
name: start_workflow
summary: >
Enter a workflow scope — narrows the visible tool catalog and gives the
agent a clear multi-step plan to follow.
description: |
Activates a named workflow defined in YAML under agent/workflows/.
Once active, only the workflow's declared tools (plus the core noyau)
are exposed to the LLM, which keeps the decision space small and
focused. The returned plan (description + steps) is the script the
agent should execute until end_workflow is called.
when_to_use: |
Use as the very first action whenever the user request maps to a
known workflow (e.g. "organize Breaking Bad" → media.organize_media).
Pass any parameters you already know (release name, target media,
flags) in 'params' so later steps can read them from STM.
when_not_to_use: |
- Do not start a workflow for purely conversational replies or
one-shot lookups that need a single tool call.
- Do not start a new workflow while one is already active — call
end_workflow first.
next_steps: |
- On status=ok: follow the returned 'steps' list, calling the tools
in order. The visible tool catalog has already been narrowed.
- On status=error (unknown_workflow): surface the available list to
the user and ask which one they meant.
- On status=error (workflow_already_active): either continue the
active workflow or call end_workflow first.
parameters:
workflow_name:
description: Fully-qualified name of the workflow to start (e.g. media.organize_media).
why_needed: |
Identifies which YAML definition to load. Names use the
'domain.action' convention (media.*, mail.*, ...).
example: media.organize_media
params:
description: Initial parameters to seed the workflow with (release name, target, flags).
why_needed: |
Later steps read these from STM instead of asking the user again.
Pass whatever you already extracted from the user's message.
example: '{"release_name": "Breaking.Bad.S01.1080p.BluRay.x265-GROUP", "keep_seeding": true}'
returns:
ok:
description: Workflow activated; catalog has been narrowed.
fields:
workflow: Name of the activated workflow.
description: Human-readable description of what the workflow does.
steps: Ordered list of steps to execute.
tools: Tools that are now visible (in addition to the core noyau).
error:
description: Could not activate the workflow.
fields:
error: Short error code (unknown_workflow, workflow_already_active).
message: Human-readable explanation.
available_workflows: List of valid workflow names (only on unknown_workflow).
active_workflow: Name of the currently active workflow (only on workflow_already_active).
+86
View File
@@ -0,0 +1,86 @@
"""Workflow scoping tools — start_workflow / end_workflow meta-tools.
These tools let the agent enter and leave a workflow scope. While a
workflow is active, the PromptBuilder narrows the visible tool catalog
to the noyau + the workflow's declared tools, so the LLM doesn't have
to reason over the full set.
"""
import logging
from typing import Any
from alfred.infrastructure.persistence import get_memory
from ..workflows import WorkflowLoader
logger = logging.getLogger(__name__)
_loader_cache: list[WorkflowLoader] = []
def _get_loader() -> WorkflowLoader:
"""Lazily build the module-level WorkflowLoader."""
if not _loader_cache:
_loader_cache.append(WorkflowLoader())
return _loader_cache[0]
def start_workflow(workflow_name: str, params: dict) -> dict[str, Any]:
"""See specs/start_workflow.yaml for full description."""
loader = _get_loader()
workflow = loader.get(workflow_name)
if workflow is None:
return {
"status": "error",
"error": "unknown_workflow",
"message": f"Workflow '{workflow_name}' not found",
"available_workflows": loader.names(),
}
memory = get_memory()
current = memory.stm.workflow.current
if current is not None:
return {
"status": "error",
"error": "workflow_already_active",
"message": (
f"Workflow '{current.get('type')}' is already active. "
"Call end_workflow before starting a new one."
),
"active_workflow": current.get("type"),
}
memory.stm.start_workflow(workflow_name, params or {})
memory.save()
logger.info(f"start_workflow: '{workflow_name}' with params={params}")
return {
"status": "ok",
"workflow": workflow_name,
"description": workflow.get("description", ""),
"steps": workflow.get("steps", []),
"tools": workflow.get("tools", []),
}
def end_workflow(reason: str) -> dict[str, Any]:
"""See specs/end_workflow.yaml for full description."""
memory = get_memory()
current = memory.stm.workflow.current
if current is None:
return {
"status": "error",
"error": "no_active_workflow",
"message": "No workflow is currently active.",
}
workflow_name = current.get("type")
memory.stm.end_workflow()
memory.save()
logger.info(f"end_workflow: '{workflow_name}' reason={reason!r}")
return {
"status": "ok",
"workflow": workflow_name,
"reason": reason,
}
@@ -14,9 +14,14 @@ trigger:
tools: tools:
- list_folder - list_folder
- analyze_release
- probe_media
- find_media_imdb_id - find_media_imdb_id
- resolve_destination - resolve_season_destination
- move_media - resolve_episode_destination
- resolve_movie_destination
- resolve_series_destination
- move_to_destination
- manage_subtitles - manage_subtitles
- create_seed_links - create_seed_links
@@ -34,22 +39,31 @@ steps:
params: params:
folder_type: download folder_type: download
- id: analyze
tool: analyze_release
description: >
Parse the release name to detect media_type (movie / tv_season /
tv_episode / tv_complete) and extract season/episode info.
- id: identify_media - id: identify_media
tool: find_media_imdb_id tool: find_media_imdb_id
description: Confirm title, type (series/movie), and metadata via TMDB. description: Confirm canonical title and year via TMDB.
- id: resolve_destination - id: resolve_destination
tool: resolve_destination
description: > description: >
Compute the correct destination path in the library. Call the resolver that matches media_type from analyze_release:
Uses the release name + TMDB metadata to build folder and file names. movie → resolve_movie_destination
If multiple series folders exist for this title, returns tv_season → resolve_season_destination
needs_clarification and the user must pick one (re-call with confirmed_folder). tv_episode → resolve_episode_destination
tv_complete → resolve_series_destination
If the resolver returns needs_clarification, ask the user and
re-call with confirmed_folder.
- id: move_file - id: move_file
tool: move_media tool: move_to_destination
description: > description: >
Move the video file to library_file returned by resolve_destination. Move the video file/folder to the destination returned by the
resolver above.
- id: handle_subtitles - id: handle_subtitles
tool: manage_subtitles tool: manage_subtitles
@@ -63,7 +77,7 @@ steps:
question: "Do you want to keep seeding this torrent?" question: "Do you want to keep seeding this torrent?"
answers: answers:
"yes": { next_step: create_seed_links } "yes": { next_step: create_seed_links }
"no": { next_step: update_library } "no": { next_step: end }
- id: create_seed_links - id: create_seed_links
tool: create_seed_links tool: create_seed_links
@@ -72,10 +86,6 @@ steps:
and copy all remaining files from the original download folder and copy all remaining files from the original download folder
(subs, nfo, jpg, …) so the torrent stays complete for seeding. (subs, nfo, jpg, …) so the torrent stays complete for seeding.
- id: update_library
memory_write: Library
description: Add the entry to the LTM library after a successful move.
naming_convention: naming_convention:
# Resolved by domain entities (Movie, Episode) — not hardcoded here # Resolved by domain entities (Movie, Episode) — not hardcoded here
tv_show: "{title}/Season {season:02d}/{title}.S{season:02d}E{episode:02d}.{ext}" tv_show: "{title}/Season {season:02d}/{title}.S{season:02d}E{episode:02d}.{ext}"