diff --git a/docs-website/docs/concepts/pipelines/serialization.mdx b/docs-website/docs/concepts/pipelines/serialization.mdx index abf8553847c..dd7d7b91241 100644 --- a/docs-website/docs/concepts/pipelines/serialization.mdx +++ b/docs-website/docs/concepts/pipelines/serialization.mdx @@ -42,7 +42,7 @@ with open("/content/test.yml", "w") as file: You can convert a YAML pipeline back into Python. Use the `loads()` method to convert a string representation of a pipeline (`str`, `bytes` or `bytearray`) or the `load()` method to convert a pipeline represented in a file-like object into a corresponding Python object. -Both loading methods support callbacks that let you modify components during the deserialization process. Therefore, loading a serialized pipeline or component assumes that the serialized definition originates from a trusted source and has been reviewed by the user. +Both loading methods support callbacks that let you modify components during the deserialization process. Deserialization is gated by a trusted-module allowlist, so pipelines referencing classes outside of it fail to load until you extend the allowlist — see [Deserialization Security](#deserialization-security) below. Here is an example script: @@ -96,6 +96,49 @@ pipe = Pipeline.loads( ) ``` +## Deserialization Security + +Loading a pipeline instantiates the classes referenced in the serialized data. To prevent a crafted YAML file from importing and instantiating arbitrary classes, `Pipeline.load`, `Pipeline.loads`, and `Pipeline.from_dict` refuse to import classes from modules outside a trusted-module allowlist and raise a `DeserializationError` instead. + +By default, the allowlist contains `haystack`, `haystack_integrations`, `haystack_experimental`, `builtins`, `typing`, and `collections`. Dangerous builtins such as `eval`, `exec`, `compile`, `__import__`, `open`, and `getattr` are blocked even though `builtins` is allowlisted. + +### Allowing Custom Modules + +Pipelines that reference custom components or callables in other packages fail to load until you add the modules to the allowlist. You can extend it in three ways: + +```python +from haystack import Pipeline + +# 1. Per call: pass additional module patterns for this deserialization only +pipe = Pipeline.load(open("pipeline.yaml"), allowed_modules=["mypkg.*"]) + +# 2. Process-wide: extend the allowlist programmatically +from haystack.core.serialization import allow_deserialization_module + +allow_deserialization_module("mypkg") +``` + +```shell +# 3. Environment variable with comma-separated patterns, read on every deserialization call +export HAYSTACK_DESERIALIZATION_ALLOWLIST="mypkg.*,otherpkg.*" +``` + +Patterns are matched as prefixes by default (`"mypkg"` matches `mypkg` and any of its submodules), or as `fnmatch` globs if they contain `*`, `?`, or `[` somewhere other than a trailing `.*`. A trailing `.*` is treated as a prefix match, so `"mypkg"` and `"mypkg.*"` behave identically. + +If the source of the serialized data is fully trusted, you can bypass the allowlist entirely with `unsafe=True`: + +```python +pipe = Pipeline.load(open("pipeline.yaml"), unsafe=True) +``` + +Only use `unsafe=True` when you fully trust where the serialized pipeline comes from — it also lifts the block on dangerous builtins. + +### Nested Init Parameter Validation + +As an additional safeguard, deserialization validates the keys of `init_parameters` against the class's `__init__` signature before recursing into any nested `{"type": "...", "init_parameters": {...}}` dictionary. A nested dictionary whose key is not an accepted parameter name is rejected with a `DeserializationError` *before* the nested type is imported, which blocks attempts to smuggle untrusted classes into unused parameter slots. Classes whose constructor takes `**kwargs` are exempt, since their accepted parameter set cannot be statically determined. + +This validation may surface pre-existing bugs in YAML files — for example typos, leftovers from renamed or removed parameters, or stale snapshots from older Haystack versions. The fix is to update the YAML so each nested-component key matches a real `__init__` parameter of the parent class. + ## Default Serialization Behavior The serialization system uses `default_to_dict` and `default_from_dict` to handle many object types automatically. You typically do **not** need to implement custom `to_dict`/`from_dict` for: diff --git a/docs-website/docs/tools/componenttool.mdx b/docs-website/docs/tools/componenttool.mdx index 71601a76cea..5e4042ceffe 100644 --- a/docs-website/docs/tools/componenttool.mdx +++ b/docs-website/docs/tools/componenttool.mdx @@ -32,6 +32,8 @@ It does input type conversion and offers support for components with run methods - Lists of dataclasses (such as List[Document]) - Parameters with mixed types (such as List[Document], str...) +If the wrapped component defines a `run_async` method, `ComponentTool` automatically wires an async invoker as well, so the tool supports async invocation (for example, from `Agent.run_async`) without extra configuration. See [Async Tools](tool.mdx#async-tools) for details. + ### Parameters - `component` is mandatory and must be a Haystack component instance, either an existing one or a custom component. diff --git a/docs-website/docs/tools/pipelinetool.mdx b/docs-website/docs/tools/pipelinetool.mdx index 3ee72c258c9..7be130f2940 100644 --- a/docs-website/docs/tools/pipelinetool.mdx +++ b/docs-website/docs/tools/pipelinetool.mdx @@ -29,6 +29,8 @@ It replaces the older workflow of first wrapping a pipeline in a `SuperComponent `PipelineTool` builds the tool parameter schema from the pipeline’s input sockets and uses the underlying components’ docstrings for input descriptions. You can choose which pipeline inputs and outputs to expose with `input_mapping` and `output_mapping`. It can be used in a pipeline with `ToolInvoker` or directly with the `Agent` component. +`PipelineTool` also supports async invocation: since every `Pipeline` exposes a native `run_async`, the tool can be awaited (for example, from `Agent.run_async`) without extra configuration. See [Async Tools](tool.mdx#async-tools) for details. + ### Parameters - `pipeline` is mandatory and must be a `Pipeline` instance. diff --git a/docs-website/docs/tools/skilltoolset.mdx b/docs-website/docs/tools/skilltoolset.mdx new file mode 100644 index 00000000000..705c9326e4f --- /dev/null +++ b/docs-website/docs/tools/skilltoolset.mdx @@ -0,0 +1,119 @@ +--- +title: "SkillToolset" +id: skilltoolset +slug: "/skilltoolset" +description: "Let agents discover and read skills — reusable instruction sets with bundled files — through progressive disclosure." +--- + +# SkillToolset + +Let agents discover and read skills — reusable instruction sets with bundled files — through progressive disclosure. + +
+ +| | | +| --- | --- | +| **Mandatory init variables** | `store`: A `SkillStore` instance that provides the skills | +| **API reference** | [SkillToolset](/reference/tools-api#skilltoolset) | +| **GitHub link** | https://github.com/deepset-ai/haystack/blob/main/haystack/tools/skills/skill_toolset.py | +| **Package name** | `haystack-ai` | + +
+ +## Overview + +A *skill* is a directory (or equivalent storage unit) containing a `SKILL.md` file with YAML frontmatter (`description` is required; `name` is optional and defaults to the directory name) and a markdown body of instructions. Skills may bundle additional files, such as reference docs, examples, or templates. + +`SkillToolset` lets an [`Agent`](../pipeline-components/agents-1/agent.mdx) use skills through *progressive disclosure*, similar to how coding assistants like Claude Code expose skills: the model first sees only each skill's name and description, loads the full instructions when a task calls for them, and fetches bundled files only when the instructions reference them. This keeps the context small even with many detailed skills. + +The toolset exposes two tools: + +- `load_skill`: Returns a skill's full instructions on demand, plus a manifest of its bundled files. The names and descriptions of all discovered skills are baked into this tool's description at warm-up, so the model can see which skills exist without any system prompt injection. +- `read_skill_file`: Reads a file bundled with a skill (with path-traversal protection). + +Skills are discovered when the toolset is warmed up — the `Agent` does this automatically before a run. Constructing the toolset does not read any skills. + +`SkillToolset` is backed by a `SkillStore`. Use the built-in `FileSystemSkillStore` to load skills from a local directory, or implement the `SkillStore` protocol (`list_skills`, `load_skill`, `read_skill_file`, plus serialization methods) to back the toolset with any storage system — a database, a remote API, and so on. + +:::info +The tool names `load_skill` and `read_skill_file` are fixed, so an `Agent` can use at most one `SkillToolset`. It also does not support adding tools or concatenation with other toolsets — to combine it with other tools, pass it to the `Agent` alongside them, for example `tools=[skills_toolset, other_tool]`. To serve skills from multiple sources, back a single toolset with a custom store that merges them. +::: + +### Skill format + +`FileSystemSkillStore` expects one sub-directory per skill under a root directory: + +``` +skills/ + pdf-forms/ + SKILL.md # frontmatter (description required, name optional) + markdown instructions + reference/forms.md # optional bundled file +``` + +A minimal `SKILL.md` looks like this: + +```markdown +--- +name: pdf-forms +description: Fill in PDF forms programmatically. Use when the user asks to complete or fill a PDF form. +--- + +# Filling PDF forms + +1. Inspect the form fields first... +2. For the full field reference, read `reference/forms.md`. +``` + +Only the frontmatter of each `SKILL.md` is read at warm-up to build the catalog; instruction bodies and bundled files are read lazily when the agent calls the corresponding tool. + +### Multimodal skill assets + +`read_skill_file` returns text files as strings, images as [`ImageContent`](../concepts/data-classes/imagecontent.mdx), and PDFs as [`FileContent`](../concepts/data-classes/filecontent.mdx). Image and file results are passed to the model as content parts of the tool result instead of being converted to a string, so an `Agent` backed by a multimodal chat generator that supports these inputs (for example, `OpenAIResponsesChatGenerator`) can read a skill's visual assets — such as a reference screenshot or a showcase PDF — directly. Binary files that are neither images nor PDFs are rejected with an error. + +### Executing bundled scripts + +`SkillToolset` only *reads* skills — `load_skill` and `read_skill_file` never execute anything. If your skills bundle executable scripts (for example, a Python helper that the instructions tell the model to run), pass a script-execution tool of your own to the `Agent` alongside the toolset: + +```python +agent = Agent( + chat_generator=OpenAIChatGenerator(), + tools=[skills_toolset, run_shell_command_tool], # your own execution tool +) +``` + +The agent can then read a bundled script with `read_skill_file` and run it through your execution tool. Since such a tool runs model-chosen commands, scope it carefully — restrict what it can execute, sandbox it, or guard it with a [Human in the Loop](../pipeline-components/agents-1/human-in-the-loop.mdx) confirmation strategy. + +## Usage + +### With an Agent + +```python +from haystack.components.agents import Agent +from haystack.components.generators.chat import OpenAIChatGenerator +from haystack.dataclasses import ChatMessage +from haystack.skill_stores.file_system import FileSystemSkillStore +from haystack.tools import SkillToolset + +store = FileSystemSkillStore("skills/") +skills_toolset = SkillToolset(store) + +agent = Agent(chat_generator=OpenAIChatGenerator(), tools=skills_toolset) + +# The agent sees the available skills in the `load_skill` tool description, +# loads the matching skill, and follows its instructions. +result = agent.run(messages=[ChatMessage.from_user("Fill in this PDF form for me.")]) +print(result["last_message"].text) +``` + +### Inspecting discovered skills + +The `skills` property returns the metadata of all discovered skills as a mapping of skill name to `SkillInfo` (warming up the toolset first if needed): + +```python +from haystack.skill_stores.file_system import FileSystemSkillStore +from haystack.tools import SkillToolset + +skills_toolset = SkillToolset(FileSystemSkillStore("skills/")) +for name, info in skills_toolset.skills.items(): + print(f"{name}: {info.description}") +``` diff --git a/docs-website/docs/tools/tool.mdx b/docs-website/docs/tools/tool.mdx index dae55f4c743..7530c080bb8 100644 --- a/docs-website/docs/tools/tool.mdx +++ b/docs-website/docs/tools/tool.mdx @@ -29,16 +29,18 @@ class Tool: name: str description: str parameters: Dict[str, Any] - function: Callable + function: Callable | None = None outputs_to_string: dict[str, Any] | None = None inputs_from_state: dict[str, str] | None = None outputs_to_state: dict[str, dict[str, Any]] | None = None + async_function: Callable | None = None ``` - `name` is the name of the Tool. - `description` is a string describing what the Tool does. - `parameters` is a JSON schema describing the expected parameters. -- `function` is invoked when the Tool is called. +- `function` is invoked when the Tool is called. It must be a regular (sync) function. +- `async_function` (optional) is a coroutine function awaited when the Tool is invoked in an async context. See [Async Tools](#async-tools) below. - `outputs_to_string` (optional) controls how parts of the tool’s output are converted into one or more strings (e.g. for LLM consumption). - `inputs_from_state` (optional) maps values from the agent state to the tool’s input parameters (e.g. to share info between tools) - `outputs_to_state` (optional) specifies how tool outputs are written back into the agent state, with optional handlers. @@ -297,6 +299,30 @@ print(result["last_message"].text) # Red apple with stem resting on straw. ``` +## Async Tools + +Tools support native async invocation. A `Tool` can carry an `async_function` (a coroutine function) alongside or instead of the sync `function`. When an [`Agent`](../pipeline-components/agents-1/agent.mdx) runs via `run_async`, it awaits the tool's `async_function` if one is available; `Tool.invoke_async` does the same when calling a tool directly. + +The `@tool` decorator and `create_tool_from_function` route `async def` callables to `async_function` automatically, so decorating an `async def` is all it takes to produce an async tool: + +```python +from typing import Annotated +from haystack.tools import tool + + +@tool +async def weather(city: Annotated[str, "The name of the city"]) -> str: + """Get the weather for a city.""" + ... +``` + +How the two fields interact: + +- If only `function` is set, `invoke_async` falls back to running the sync function in a worker thread, so sync tools keep working in async contexts. +- If only `async_function` is set, the tool can only be invoked asynchronously — calling the sync `invoke` raises a `ToolInvocationError`. + +[`ComponentTool`](componenttool.mdx) automatically wires an async invoker for components that define `run_async`, and [`PipelineTool`](pipelinetool.mdx) inherits this behavior — in Haystack 3.0 every `Pipeline` exposes a native `run_async`, so pipeline tools support the async path out of the box. + ## Toolset A Toolset groups multiple Tool instances into a single manageable unit. diff --git a/docs-website/sidebars.js b/docs-website/sidebars.js index e4d7c0abe9f..230448083fe 100644 --- a/docs-website/sidebars.js +++ b/docs-website/sidebars.js @@ -701,6 +701,7 @@ export default { 'tools/mcptool', 'tools/mcptoolset', 'tools/searchabletoolset', + 'tools/skilltoolset', { type: 'category', label: 'Ready-made Tools',