diff --git a/alfred/agent/agent.py b/alfred/agent/agent.py index b17dc60..31a4252 100644 --- a/alfred/agent/agent.py +++ b/alfred/agent/agent.py @@ -10,6 +10,7 @@ from alfred.settings import settings from .prompt import PromptBuilder from .registry import Tool, make_tools +from .workflows import WorkflowLoader logger = logging.getLogger(__name__) @@ -33,8 +34,8 @@ class Agent: self.settings = settings self.llm = llm self.tools: dict[str, Tool] = make_tools(settings) - self.prompt_builder = PromptBuilder(self.tools) - self.settings = settings + self.workflow_loader = WorkflowLoader() + self.prompt_builder = PromptBuilder(self.tools, self.workflow_loader) self.max_tool_iterations = max_tool_iterations def step(self, user_input: str) -> str: @@ -168,6 +169,19 @@ class Agent: "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] # Execute tool diff --git a/alfred/agent/prompt.py b/alfred/agent/prompt.py index 4a1d272..aa93bd4 100644 --- a/alfred/agent/prompt.py +++ b/alfred/agent/prompt.py @@ -8,15 +8,56 @@ from alfred.infrastructure.persistence.memory import MemoryRegistry from .expressions import build_expressions_context 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: """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.workflow_loader = workflow_loader or WorkflowLoader() 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: """Build Alfred's identity and personality section.""" username = memory.stm.get_entity("username") @@ -58,9 +99,12 @@ EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta r {expressions_block}""" 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 = [] for tool in self.tools.values(): + if tool.name not in visible: + continue spec = { "type": "function", "function": { @@ -73,15 +117,57 @@ EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta r return tool_specs def _format_tools_description(self) -> str: - """Format tools with their descriptions and parameters.""" - if not self.tools: + """Format the currently-visible tools with description + params.""" + 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 "\n".join( f"- {tool.name}: {tool.description}\n" 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: """Format episodic memory context for the prompt.""" lines = [] @@ -213,7 +299,10 @@ EXPRESSIONS À UTILISER (une par situation, naturellement intégrées dans ta r # 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_section = f"\nOUTILS DISPONIBLES:\n{tools_desc}" if tools_desc else "" @@ -233,6 +322,7 @@ RÈGLES: stm_context, episodic_context, memory_schema, + workflow_section, tools_section, rules, ] diff --git a/alfred/agent/registry.py b/alfred/agent/registry.py index 2365402..e45480b 100644 --- a/alfred/agent/registry.py +++ b/alfred/agent/registry.py @@ -129,6 +129,7 @@ def make_tools(settings) -> dict[str, Tool]: from .tools import api as api_tools # noqa: PLC0415 from .tools import filesystem as fs_tools # noqa: PLC0415 from .tools import language as lang_tools # noqa: PLC0415 + from .tools import workflow as wf_tools # noqa: PLC0415 tool_functions = [ 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.get_torrent_by_index, lang_tools.set_language, + wf_tools.start_workflow, + wf_tools.end_workflow, ] specs = load_tool_specs() diff --git a/alfred/agent/tools/specs/end_workflow.yaml b/alfred/agent/tools/specs/end_workflow.yaml new file mode 100644 index 0000000..3202091 --- /dev/null +++ b/alfred/agent/tools/specs/end_workflow.yaml @@ -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. diff --git a/alfred/agent/tools/specs/start_workflow.yaml b/alfred/agent/tools/specs/start_workflow.yaml new file mode 100644 index 0000000..59298cc --- /dev/null +++ b/alfred/agent/tools/specs/start_workflow.yaml @@ -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). diff --git a/alfred/agent/tools/workflow.py b/alfred/agent/tools/workflow.py new file mode 100644 index 0000000..5910c55 --- /dev/null +++ b/alfred/agent/tools/workflow.py @@ -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, + } diff --git a/alfred/agent/workflows/media.organize_media.yaml b/alfred/agent/workflows/media.organize_media.yaml index 27f82f2..2be56b7 100644 --- a/alfred/agent/workflows/media.organize_media.yaml +++ b/alfred/agent/workflows/media.organize_media.yaml @@ -14,9 +14,14 @@ trigger: tools: - list_folder + - analyze_release + - probe_media - find_media_imdb_id - - resolve_destination - - move_media + - resolve_season_destination + - resolve_episode_destination + - resolve_movie_destination + - resolve_series_destination + - move_to_destination - manage_subtitles - create_seed_links @@ -34,22 +39,31 @@ steps: params: 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 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 - tool: resolve_destination description: > - Compute the correct destination path in the library. - Uses the release name + TMDB metadata to build folder and file names. - If multiple series folders exist for this title, returns - needs_clarification and the user must pick one (re-call with confirmed_folder). + Call the resolver that matches media_type from analyze_release: + movie → resolve_movie_destination + tv_season → resolve_season_destination + 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 - tool: move_media + tool: move_to_destination 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 tool: manage_subtitles @@ -63,7 +77,7 @@ steps: question: "Do you want to keep seeding this torrent?" answers: "yes": { next_step: create_seed_links } - "no": { next_step: update_library } + "no": { next_step: end } - id: create_seed_links tool: create_seed_links @@ -72,10 +86,6 @@ steps: and copy all remaining files from the original download folder (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: # Resolved by domain entities (Movie, Episode) — not hardcoded here tv_show: "{title}/Season {season:02d}/{title}.S{season:02d}E{episode:02d}.{ext}"