Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions agent_state_access_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0

"""
POC: Agent State Access - skills-as-state pattern

The agent has two skills stored as strings in state:
- summarize_skill: a prompt telling the agent how to summarise text
- translate_skill: a prompt telling the agent how to translate text

Rather than loading both into the system prompt upfront, the agent discovers
available skills via ls_state, loads the one it needs via read_state, and writes
its final output to a `final_answer` key via write_state.
"""

from haystack.components.agents import Agent
from haystack.components.agents.state import LsStateTool, ReadStateTool, WriteStateTool
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.generators.utils import print_streaming_chunk
from haystack.dataclasses import ChatMessage

agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-5.4"),
tools=[LsStateTool(), ReadStateTool(), WriteStateTool()],
state_schema={"summarize_skill": {"type": str}, "translate_skill": {"type": str}, "final_answer": {"type": str}},
system_prompt="""You are a helpful assistant.
Use ls_state to discover available state keys, read_state to read their values, and write_state to record your final
response in the `final_answer` key.

If you see a key that ends in `_skill`, it contains instructions for how to perform a specific task.
Use these instructions to guide your actions.""",
)

result = agent.run(
messages=[
ChatMessage.from_user(
"""Please summarise the following text:

Haystack is an open-source AI orchestration framework that you can use to build powerful, production-ready applications with Large Language Models (LLMs) for various use cases. Whether you’re creating autonomous agents, multimodal apps, or scalable RAG systems, Haystack provides the tools to move from idea to production easily.
Haystack is designed in a modular way, allowing you to combine the best technology from OpenAI, Google, Anthropic, and open-source projects like Hugging Face's Transformers.
The core foundation of Haystack consists of components and pipelines, along with Document Stores, Agents, Tools, and many integrations. Read more about Haystack concepts in the Haystack Concepts Overview.
Supported by an engaged community of developers, Haystack has grown into a comprehensive and user-friendly framework for LLM-based development.
""" # noqa: E501
)
],
summarize_skill=(
"To summarise text: identify the main topic, strip filler words, and return a single concise sentence."
),
translate_skill=(
"To translate text: preserve meaning and tone exactly, and return only the translated text without commentary."
),
streaming_callback=print_streaming_chunk,
)

print("Final answer:", result["final_answer"])
9 changes: 8 additions & 1 deletion haystack/components/agents/state/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@

from lazy_imports import LazyImporter

_import_structure = {"state": ["State", "merge_lists", "replace_values"]}
_import_structure = {
"state": ["State", "merge_lists", "replace_values"],
"state_tools": ["LsStateTool", "ReadStateTool", "WriteStateTool", "StateToolset"],
}

if TYPE_CHECKING:
from .state import State as State
from .state_tools import LsStateTool as LsStateTool
from .state_tools import ReadStateTool as ReadStateTool
from .state_tools import StateToolset as StateToolset
from .state_tools import WriteStateTool as WriteStateTool
from .state_utils import merge_lists as merge_lists
from .state_utils import replace_values as replace_values

Expand Down
226 changes: 226 additions & 0 deletions haystack/components/agents/state/state_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0

from typing import Any

from haystack.components.agents.state.state import State
from haystack.core.serialization import generate_qualified_class_name
from haystack.tools import Tool, Toolset


def _ls_state(state: State) -> str:
"""List all available keys in the agent state and their types."""
lines = []
for key, definition in state.schema.items():
type_name = getattr(definition["type"], "__name__", str(definition["type"]))
lines.append(f"- {key} ({type_name})")
return "\n".join(lines)


def _read_state(key: str, state: State, truncate: bool = True) -> str:
"""Read the value of a key from the agent state."""
if not state.has(key):
return f"Key '{key}' not found in state. Call ls_state to see available keys."
value = repr(state.get(key))
if truncate and len(value) > 200:
value = value[:200] + "... [truncated, call cat_state with truncate=False to see full value]"
return value


def _write_state(key: str, value: str, state: State) -> str:
"""Write a string value to a key in the agent state. Only string-typed keys are supported."""
definition = state.schema.get(key)
if definition is None:
return f"Key '{key}' not found in state. Call ls_state to see available keys."
if definition["type"] is not str:
type_name = getattr(definition["type"], "__name__", str(definition["type"]))
return f"Key '{key}' has type '{type_name}'. write_state only supports string-typed keys."
state.set(key, value)
return f"State key '{key}' updated successfully."


class LsStateTool(Tool):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although it's clear to me what's the purpose of this tool, wouldn't it be a better idea to just call it ListStateTool? It would make it more explicit, and I imagine some users can be unfamiliar with unix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's fair happy to go with ListStateTool

"""
A pre-built tool that lists all keys and their types in the agent state.

The agent can call this as a cheap first step to discover what state is available
before deciding whether to read any values.
"""

def __init__(
self,
*,
name: str = "ls_state",
description: str = "List all available keys in the agent state and their types.",
) -> None:
"""
Initialize the LsStateTool.

:param name: Name of the tool.
:param description: Description of the tool shown to the LLM.
"""
super().__init__(
name=name, description=description, parameters={"type": "object", "properties": {}}, function=_ls_state
)

def to_dict(self) -> dict[str, Any]:
"""
Serializes the tool to a dictionary.

:returns: Dictionary with serialized data.
"""
return {
"type": generate_qualified_class_name(type(self)),
"data": {"name": self.name, "description": self.description},
}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "LsStateTool":
"""
Deserializes the tool from a dictionary.

:param data: Dictionary to deserialize from.
:returns: Deserialized tool.
"""
return cls(**data["data"])


class ReadStateTool(Tool):
"""
A pre-built tool that reads the value of a single key from the agent state.
"""

def __init__(
self,
*,
name: str = "read_state",
description: str = "Read the value of a key from the agent state.",
key_description: str = "The state key to read. Call ls_state to see available keys.",
truncate_description: str = (
"If True, the value is truncated to 200 characters. Set to False to see the full value."
),
) -> None:
"""
Initialize the CatStateTool.

:param name: Name of the tool.
:param description: Description of the tool shown to the LLM.
:param key_description: Description of the `key` parameter shown to the LLM.
:param truncate_description: Description of the `truncate` parameter shown to the LLM.
"""
self.key_description = key_description
self.truncate_description = truncate_description

super().__init__(
name=name,
description=description,
parameters={
"type": "object",
"properties": {
"key": {"type": "string", "description": key_description},
"truncate": {"type": "boolean", "description": truncate_description, "default": False},
},
"required": ["key"],
},
function=_read_state,
)

def to_dict(self) -> dict[str, Any]:
"""
Serializes the tool to a dictionary.

:returns: Dictionary with serialized data.
"""
return {
"type": generate_qualified_class_name(type(self)),
"data": {
"name": self.name,
"description": self.description,
"key_description": self.key_description,
"truncate_description": self.truncate_description,
},
}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ReadStateTool":
"""
Deserializes the tool from a dictionary.

:param data: Dictionary to deserialize from.
:returns: Deserialized tool.
"""
return cls(**data["data"])


class WriteStateTool(Tool):
"""
A pre-built tool that writes a string value to a key in the agent state.

Only string-typed state keys are supported. This is primarily useful for the agent to surface structured string
outputs (e.g. a `final_answer` field) that downstream pipeline components can consume.
"""

def __init__(
self,
*,
name: str = "write_state",
description: str = "Write a string value to a key in the agent state. Only string-typed keys are supported.",
key_description: str = (
"The state key to write. Must be a string-typed key. Call ls_state to see available keys."
),
value_description: str = "The string value to write.",
) -> None:
"""
Initialize the WriteStateTool.

:param name: Name of the tool.
:param description: Description of the tool shown to the LLM.
:param key_description: Description of the `key` parameter shown to the LLM.
:param value_description: Description of the `value` parameter shown to the LLM.
"""
self.key_description = key_description
self.value_description = value_description

super().__init__(
name=name,
description=description,
parameters={
"type": "object",
"properties": {
"key": {"type": "string", "description": key_description},
"value": {"type": "string", "description": value_description},
},
"required": ["key", "value"],
},
function=_write_state,
)

def to_dict(self) -> dict[str, Any]:
"""
Serializes the tool to a dictionary.

:returns: Dictionary with serialized data.
"""
return {
"type": generate_qualified_class_name(type(self)),
"data": {
"name": self.name,
"description": self.description,
"key_description": self.key_description,
"value_description": self.value_description,
},
}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WriteStateTool":
"""
Deserializes the tool from a dictionary.

:param data: Dictionary to deserialize from.
:returns: Deserialized tool.
"""
return cls(**data["data"])


StateToolset = Toolset([LsStateTool(), ReadStateTool(), WriteStateTool()])
26 changes: 16 additions & 10 deletions haystack/components/tools/tool_invoker.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,22 +376,23 @@ def _prepare_tool_result_message(self, result: Any, tool_call: ToolCall, tool_to
return ChatMessage.from_tool(tool_result=str(e), origin=tool_call, error=True)

@staticmethod
def _get_func_params(tool: Tool) -> set:
def _get_func_params(tool: Tool) -> dict[str, Any]:
"""
Returns the function parameters of the tool's invoke method.
Returns the function parameters with types of the tool's invoke method.

This method inspects the tool's function signature to determine which parameters the tool accepts.
:param tool: The tool for which to get the function parameters and their types.
"""
# ComponentTool wraps the function with a function that accepts kwargs, so we need to look at input sockets
# to find out which parameters the tool accepts.
if isinstance(tool, ComponentTool):
# mypy doesn't know that ComponentMeta always adds __haystack_input__ to Component
assert hasattr(tool._component, "__haystack_input__") and isinstance(
tool._component.__haystack_input__, Sockets
)
func_params = set(tool._component.__haystack_input__._sockets_dict.keys())
func_params = {
name: socket.type for name, socket in tool._component.__haystack_input__._sockets_dict.items()
}
else:
func_params = set(inspect.signature(tool.function).parameters.keys())
func_params = {
name: param.annotation for name, param in inspect.signature(tool.function).parameters.items()
}

return func_params

Expand All @@ -406,7 +407,7 @@ def _inject_state_args(tool: Tool, llm_args: dict[str, Any], state: State) -> di
- function signature name matching
"""
final_args = dict(llm_args) # start with LLM-provided
func_params = ToolInvoker._get_func_params(tool)
func_params = ToolInvoker._get_func_params(tool).keys()

# Determine the source of parameter mappings (explicit tool inputs or direct function parameters)
# Typically, a "Tool" might have .inputs_from_state = {"state_key": "tool_param_name"}
Expand All @@ -420,6 +421,11 @@ def _inject_state_args(tool: Tool, llm_args: dict[str, Any], state: State) -> di
if param_name not in final_args and state.has(state_key):
final_args[param_name] = state.get(state_key)

# Inject the live State object for any parameter annotated as State
for param_name, param_type in ToolInvoker._get_func_params(tool).items():
if param_type is State:
final_args[param_name] = state

return final_args

@staticmethod
Expand Down Expand Up @@ -521,7 +527,7 @@ def _prepare_tool_call_params(
enable_streaming_passthrough
and streaming_callback is not None
and "streaming_callback" not in final_args
and "streaming_callback" in self._get_func_params(tool_to_invoke)
and "streaming_callback" in self._get_func_params(tool_to_invoke).keys()
):
final_args["streaming_callback"] = streaming_callback

Expand Down
6 changes: 6 additions & 0 deletions haystack/tools/from_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from pydantic import create_model

from haystack.components.agents.state.state import State

from .errors import SchemaGenerationError
from .parameters_schema_utils import _contains_callable_type
from .tool import Tool
Expand Down Expand Up @@ -139,6 +141,10 @@ def get_weather(
if inputs_from_state and param_name in inputs_from_state.values():
continue

# Skip State-typed parameters - ToolInvoker injects them at runtime
if param.annotation is State:
continue

if param.annotation is param.empty:
raise ValueError(f"Function '{function.__name__}': parameter '{param_name}' does not have a type hint.")

Expand Down
Loading
Loading