Skip to content
Open
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e68df13
feat: add baseline skills
olbychos Jan 8, 2026
b6fb41c
feat: add baseline skills
olbychos Jan 8, 2026
1a66201
fix: merge to latest main
olbychos Jan 8, 2026
800e59c
fix: merge to latest main
olbychos Jan 8, 2026
3540476
chore: lint
olbychos Jan 8, 2026
d22e982
Merge branch 'main' into feat/add_agent_skills
maksymbuleshnyi Jan 14, 2026
d5f06ac
Merge branch 'main' into feat/add_agent_skills
maksymbuleshnyi Jan 14, 2026
6b325ec
feat: restructure skills
olbychos Jan 30, 2026
6e1862b
chore: resolve mr confict
olbychos Jan 30, 2026
16286d3
fix: align baseline
olbychos Jan 30, 2026
9677094
fix: bugbot gaps
olbychos Jan 30, 2026
28cb87a
fix: serialization
olbychos Jan 30, 2026
327e386
feat: add dynamiq registry
olbychos Feb 2, 2026
093f4d2
Merge branch 'main' into feat/add_agent_skills
olbychos Feb 2, 2026
fa6835f
fix: serialization
olbychos Feb 2, 2026
e88cc46
fix: serialization
olbychos Feb 2, 2026
f9a3980
fix: tests
olbychos Feb 2, 2026
ce37b51
fix: tests
olbychos Feb 2, 2026
95c29ad
fix: comments,config,init
olbychos Feb 2, 2026
b49e4bf
fix: annotation
olbychos Feb 2, 2026
7da427e
fix: clean models and config
olbychos Feb 2, 2026
e6c4d86
Merge branch 'main' into feat/add_agent_skills
olbychos Feb 3, 2026
4819dce
chore: upd example
olbychos Feb 3, 2026
f2ea596
fix: refactor registry, upd examples
olbychos Feb 3, 2026
47b47f5
fix: refactor registry, upd examples
olbychos Feb 3, 2026
b823947
fix: config
olbychos Feb 3, 2026
fb8dda7
chore: skip qdrant test
olbychos Feb 3, 2026
220fd6b
fix: skills init
olbychos Feb 3, 2026
203d83a
fix: resolve comments
olbychos Feb 4, 2026
189f1ce
fix: resolve comments
olbychos Feb 4, 2026
eecd5c0
Merge branch 'main' into feat/add_agent_skills
olbychos Feb 4, 2026
663a436
Merge branch 'main' into feat/add_agent_skills
olbychos Feb 4, 2026
375555c
fix: revert yaml
olbychos Feb 5, 2026
302895f
fix: connection issues
olbychos Feb 5, 2026
b830bbb
fix: comments, types, skills registry
olbychos Feb 5, 2026
1f82288
fix: local registry
olbychos Feb 5, 2026
4c32c85
feat: add logs skillstools
olbychos Feb 5, 2026
0aa8ea5
Merge remote-tracking branch 'origin/feat/add_agent_skills' into feat…
olbychos Feb 5, 2026
2766ca3
fix: test,revert yaml
olbychos Feb 5, 2026
410ae51
fix: errors
olbychos Feb 5, 2026
06c01f8
feat: upd local skills
olbychos Feb 5, 2026
ef0b5ae
feat: add recoverable error, add space
olbychos Feb 6, 2026
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
5 changes: 3 additions & 2 deletions dynamiq/nodes/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1170,11 +1170,12 @@ def _init_prompt_blocks(self):
self.tools, self.sanitize_tool_name, self.delegation_allowed
)

# Setup ReAct-specific prompts via prompt manager
# Setup ReAct-specific prompts via prompt manager.
has_tools = bool(self.tools) or (self.skills.enabled and self.skills.source is not None)
self.system_prompt_manager.setup_for_react_agent(
inference_mode=self.inference_mode,
parallel_tool_calls_enabled=self.parallel_tool_calls_enabled,
has_tools=bool(self.tools),
has_tools=has_tools,
delegation_allowed=self.delegation_allowed,
context_compaction_enabled=self.summarization_config.enabled,
todo_management_enabled=self.file_store.enabled and self.file_store.todo_enabled,
Expand Down
64 changes: 63 additions & 1 deletion dynamiq/nodes/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@
from dynamiq.nodes.tools.parallel_tool_calls import PARALLEL_TOOL_NAME, ParallelToolCallsTool
from dynamiq.nodes.tools.python import Python
from dynamiq.nodes.tools.python_code_executor import PythonCodeExecutor
from dynamiq.nodes.tools.skills_tool import SkillsTool
from dynamiq.prompts import Message, MessageRole, Prompt, VisionMessage, VisionMessageTextContent
from dynamiq.runnables import RunnableConfig, RunnableResult, RunnableStatus
from dynamiq.skills.config import SkillsConfig
from dynamiq.skills.types import SkillMetadata
from dynamiq.storages.file.base import FileStore, FileStoreConfig
from dynamiq.storages.file.in_memory import InMemoryFileStore
from dynamiq.utils.logger import logger
Expand Down Expand Up @@ -216,6 +219,10 @@ class Agent(Node):
default=512,
description="Maximum number of bytes/characters from each uploaded file to surface as an inline preview.",
)
skills: SkillsConfig = Field(
default_factory=SkillsConfig,
description="Skills config. When enabled and source registry is set, skills are on (Dynamiq or FileSystem).",
)

input_message: Message | VisionMessage | None = None
role: str | None = Field(
Expand All @@ -227,6 +234,7 @@ class Agent(Node):
description: str | None = Field(default=None, description="Short human-readable description of the agent.")
_mcp_servers: list[MCPServer] = PrivateAttr(default_factory=list)
_mcp_server_tool_ids: list[str] = PrivateAttr(default_factory=list)
_skills_tool_ids: list[str] = PrivateAttr(default_factory=list)
_tool_cache: dict[ToolCacheEntry, Any] = {}
_history_offset: int = PrivateAttr(
default=2, # Offset to the first message (default: 2 — system and initial user messages).
Expand Down Expand Up @@ -308,7 +316,11 @@ def __init__(self, **kwargs):
self.tools = [t for t in self.tools if t.name != PARALLEL_TOOL_NAME]
self.tools.append(ParallelToolCallsTool())

if self._skills_should_init():
self._init_skills()
self._init_prompt_blocks()
if self._skills_should_init():
self._apply_skills_to_prompt()

@model_validator(mode="after")
def validate_input_fields(self):
Expand All @@ -335,6 +347,7 @@ def to_dict_exclude_params(self):
"files": True,
"images": True,
"file_store": True,
"skills": True,
"system_prompt_manager": True, # Runtime state container, not serializable
}

Expand All @@ -343,7 +356,8 @@ def to_dict(self, **kwargs) -> dict:
data = super().to_dict(**kwargs)
data["llm"] = self.llm.to_dict(**kwargs)

data["tools"] = [tool.to_dict(**kwargs) for tool in self.tools if tool.id not in self._mcp_server_tool_ids]
excluded_tool_ids = set(self._mcp_server_tool_ids) | set(self._skills_tool_ids)
data["tools"] = [tool.to_dict(**kwargs) for tool in self.tools if tool.id not in excluded_tool_ids]
data["tools"] = data["tools"] + [mcp_server.to_dict(**kwargs) for mcp_server in self._mcp_servers]

data["memory"] = self.memory.to_dict(**kwargs) if self.memory else None
Expand All @@ -354,6 +368,8 @@ def to_dict(self, **kwargs) -> dict:

data["file_store"] = self.file_store.to_dict(**kwargs) if self.file_store else None

data["skills"] = self.skills.to_dict(**kwargs)

return data

def init_components(self, connection_manager: ConnectionManager | None = None):
Expand Down Expand Up @@ -386,6 +402,52 @@ def _init_prompt_blocks(self):
self.system_prompt_manager = AgentPromptManager(model_name=model_name, tool_description=self.tool_description)
self.system_prompt_manager.setup_for_base_agent()

def _skills_should_init(self) -> bool:
"""True if skills support should be initialized (enabled and source set)."""
return self.skills.enabled and self.skills.source is not None

def _init_skills(self) -> None:
"""Add SkillsTool to self.tools so it is included in function-calling and structured-output schemas."""

source = self.skills.source
if source is None:
logger.warning("Skills config missing or invalid (source required); skipping skills init")
return
skills_tool = SkillsTool(skill_registry=source)
self.tools.append(skills_tool)
self._skills_tool_ids.append(skills_tool.id)

def _apply_skills_to_prompt(self) -> None:
"""Set skills block and tool_description on the prompt manager after _init_prompt_blocks()."""
source = self.skills.source
if source is None:
return
metadata = self.skills.get_skills_metadata()
skills_summary = self._format_skills_summary(metadata)
self.system_prompt_manager.set_block("skills", skills_summary)
self.system_prompt_manager.set_initial_variable("tool_description", self.tool_description)
logger.info(
f"Agent {self.name} - {self.id}: initialized with {len(metadata)} skills "
f"(source={source.__class__.__name__})"
)

def _format_skills_summary(self, metadata: list[SkillMetadata]) -> str:
"""Format skills summary for prompt.

Args:
metadata: List of SkillMetadata objects.

Returns:
Formatted string with skill information.
"""
if not metadata:
return ""

lines = []
for skill in metadata:
lines.append(f"- **{skill.name}**: {skill.description}")
return "\n".join(lines)

def set_block(self, block_name: str, content: str):
"""Adds or updates a prompt block."""
self.system_prompt_manager.set_block(block_name, content)
Expand Down
19 changes: 19 additions & 0 deletions dynamiq/nodes/agents/prompts/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@
# AVAILABLE TOOLS
{{tools}}
{%- endif %}
{%- if skills %}
# AVAILABLE SKILLS (SkillsTool)
The list below is for orientation only.
You must use the SkillsTool to read skill content before applying it;
do not rely on the short descriptions.

{{skills}}

## Obligatory use of SkillsTool
Using the SkillsTool is required for any skill-related task. Do not answer using a skill without calling the tool first.
- Almost always read content: Call action="get" with skill_name="..." to
load the full skill instructions before applying.
The descriptions above are approximate; the actual guidelines are in the skill content.
- List if needed: Use action="list" to see available skill names and descriptions.
- Get then apply: After action="get", the tool returns the skill instructions.
Apply them yourself in your reasoning and provide the result in your final answer.
Do not call the tool again with user content to transform.
For large skills use section="Section title" or line_start/line_end (1-based) to read only a part.
{%- endif %}

{%- if output_format %}
# RESPONSE FORMAT
Expand Down
1 change: 1 addition & 0 deletions dynamiq/nodes/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .python import Python
from .python_code_executor import PythonCodeExecutor
from .scale_serp import ScaleSerpTool
from .skills_tool import SkillsTool
from .sql_executor import SQLExecutor
from .tavily import TavilyTool
from .thinking_tool import ThinkingTool
Expand Down
150 changes: 150 additions & 0 deletions dynamiq/nodes/tools/skills_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
from enum import Enum
from typing import Any, ClassVar, Literal

from pydantic import BaseModel, Field

from dynamiq.nodes import Node, NodeGroup
from dynamiq.nodes.agents.exceptions import ToolExecutionException
from dynamiq.runnables import RunnableConfig
from dynamiq.skills import BaseSkillRegistry
from dynamiq.skills.utils import extract_skill_content_slice
from dynamiq.utils.logger import logger


class SkillsToolAction(str, Enum):
"""Action for the Skills tool."""

LIST = "list"
GET = "get"


class SkillsToolInputSchema(BaseModel):
"""Input schema for Skills tool. Actions: list (discover), get (full or partial content)."""

action: SkillsToolAction = Field(
...,
description="Action: 'list' discover skills, 'get' full or partial skill content.",
)
skill_name: str | None = Field(default=None, description="Skill name (required for get)")
section: str | None = Field(
default=None,
description="For get: return only this markdown section (e.g. 'Welcome messages')",
)
line_start: int | None = Field(default=None, description="For get: 1-based start line (body only)")
line_end: int | None = Field(default=None, description="For get: 1-based end line (inclusive)")


class SkillsTool(Node):
"""Tool for skills: discover and get content from a skill registry (Dynamiq or FileSystem).

After get, apply the skill's instructions yourself and provide the result in your final answer.
"""

group: Literal[NodeGroup.TOOLS] = NodeGroup.TOOLS
name: str = "SkillsTool"
description: str = (
"Manages skills (instructions only). Use this tool to:\n"
"- List available skills: action='list'\n"
"- Get skill content: action='get', skill_name='...'. "
"For large skills use section='Section title' or line_start/line_end to read only a part.\n\n"
"After get, apply the skill's instructions yourself in your reasoning and provide the result "
"in your final answer. Do not call the tool again with user content to transform; "
"the tool only provides instructions; you produce the output."
)

skill_registry: BaseSkillRegistry = Field(
...,
description="Registry providing skills (Dynamiq or FileSystem).",
)
input_schema: ClassVar[type[SkillsToolInputSchema]] = SkillsToolInputSchema

@property
def to_dict_exclude_params(self):
return super().to_dict_exclude_params | {
"skill_registry": True,
}

def execute(
self, input_data: SkillsToolInputSchema, config: RunnableConfig | None = None, **kwargs
) -> dict[str, Any]:
action = input_data.action
logger.info("SkillsTool - action=%s", action.value)

if action == SkillsToolAction.LIST:
return self._list_skills()
if action == SkillsToolAction.GET:
if not input_data.skill_name:
raise ToolExecutionException("skill_name required for get", recoverable=True)
return self._get_skill(
input_data.skill_name,
section=input_data.section,
line_start=input_data.line_start,
line_end=input_data.line_end,
)
raise ToolExecutionException(f"Unknown action: {action.value}", recoverable=True)

def _list_skills(self) -> dict[str, Any]:
metadata_list = self.skill_registry.get_skills_metadata()
skills_info = [{"name": m.name, "description": m.description} for m in metadata_list]
names = [m.name for m in metadata_list]
logger.info("SkillsTool - list: %d skill(s) %s", len(metadata_list), names)
return {
"content": {
"available_skills": skills_info,
"total": len(metadata_list),
}
}

def _get_skill(
self,
skill_name: str,
section: str | None = None,
line_start: int | None = None,
line_end: int | None = None,
) -> dict[str, Any]:
try:
instructions = self.skill_registry.get_skill_instructions(skill_name)
except Exception as e:
raise ToolExecutionException(f"Failed to get skill '{skill_name}': {e}", recoverable=True) from e

def _content_dict(sliced_instructions: str, section_used: str | None = None) -> dict[str, Any]:
out: dict[str, Any] = {
"name": instructions.name,
"description": instructions.description,
"instructions": sliced_instructions,
}
if section_used is not None:
out["section_used"] = section_used
if instructions.metadata:
out["metadata"] = instructions.metadata
return out

if section is not None or line_start is not None or line_end is not None:
sliced, section_used = extract_skill_content_slice(
instructions.instructions,
section=section,
line_start=line_start,
line_end=line_end,
)
one_line = sliced.replace("\n", " ").strip()
preview = (one_line[:50] + "...") if len(one_line) > 50 else one_line
logger.info(
"SkillsTool - get: skill=%s (section=%s, lines=%s-%s) -> content received (%d chars), preview: %s",
skill_name,
section,
line_start,
line_end,
len(sliced),
preview,
)
return {"content": _content_dict(sliced, section_used)}

one_line = instructions.instructions.replace("\n", " ").strip()
preview = (one_line[:50] + "...") if len(one_line) > 50 else one_line
logger.info(
"SkillsTool - get: skill=%s -> content received (%d chars), preview: %s",
skill_name,
len(instructions.instructions),
preview,
)
return {"content": _content_dict(instructions.instructions)}
11 changes: 6 additions & 5 deletions dynamiq/serializers/loaders/yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,18 +72,19 @@ def _get_requirements_from_list(cls, items: list, requirements: list[Requirement
@staticmethod
def is_node_type(type_value: str | None) -> bool:
"""
Check if the type value represents a node type (dotted path like 'module.ClassName').
Check if the type value represents a node type (dotted path under dynamiq.nodes).

Node types use dotted path format (e.g., 'dynamiq.nodes.agents.Agent'),
while other types (e.g., JSON schema types like 'string', 'object') don't contain dots.
Only types under 'dynamiq.nodes.' are treated as nodes so that nested configs
(e.g. dynamiq.skills.registries.*, dynamiq.connections.*) are not turned into
nodes and passed to get_node_connection.

Args:
type_value: The type string to check.

Returns:
True if the type value is a node type (contains a dot), False otherwise.
True if the type value is a node type (dynamiq.nodes.*), False otherwise.
"""
return bool(type_value and "." in type_value)
return bool(type_value and type_value.startswith("dynamiq.nodes."))

@classmethod
def apply_resolved_requirements(
Expand Down
14 changes: 14 additions & 0 deletions dynamiq/skills/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Public API: config, types, registries only.
from dynamiq.skills.config import SkillsConfig
from dynamiq.skills.registries import BaseSkillRegistry, Dynamiq, FileSystem
from dynamiq.skills.types import SkillInstructions, SkillMetadata, SkillRegistryError

__all__ = [
Copy link
Collaborator

Choose a reason for hiding this comment

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

It seems this is obsolete as we don't use import *

"BaseSkillRegistry",
"Dynamiq",
"FileSystem",
"SkillInstructions",
"SkillMetadata",
"SkillRegistryError",
"SkillsConfig",
]
Loading
Loading