Skip to content
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
41 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
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
79 changes: 78 additions & 1 deletion dynamiq/nodes/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
from dynamiq.nodes.tools.python_code_executor import PythonCodeExecutor
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.models 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 +218,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 Local).",
)

input_message: Message | VisionMessage | None = None
role: str | None = Field(
Expand All @@ -227,6 +233,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 +315,22 @@ 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="before")
@classmethod
def coerce_skills_config(cls, data: Any) -> Any:
"""Coerce skills dict (from YAML/JSON) to SkillsConfig."""
if isinstance(data, dict) and "skills" in data:
if isinstance(data["skills"], dict):
data = {**data, "skills": SkillsConfig.model_validate(data["skills"])}
elif data["skills"] is None:
data = {**data, "skills": SkillsConfig()}
return data

@model_validator(mode="after")
def validate_input_fields(self):
Expand All @@ -335,6 +357,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 +366,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 +378,12 @@ 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.model_dump() if self.skills else None
if self.skills and self.skills.source is not None:
source = self.skills.source
if "source" in data["skills"] and getattr(source, "connection", None) is not None:
data["skills"]["source"]["connection"] = source.connection.to_dict(**kwargs)

return data

def init_components(self, connection_manager: ConnectionManager | None = None):
Expand Down Expand Up @@ -386,6 +416,53 @@ 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."""
from dynamiq.nodes.tools.skills_tool import SkillsTool

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
135 changes: 135 additions & 0 deletions dynamiq/nodes/tools/skills_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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 SkillsToolInputSchema(BaseModel):
"""Input schema for Skills tool. Actions: list (discover), get (full or partial content)."""

action: Literal["list", "get"] = 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 Local).

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 Local).",
)
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)

if action == "list":
return self._list_skills()
if action == "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}", 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"Skill '{skill_name}' not found: {e}", recoverable=True) from e

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,
)
out = {
"name": instructions.name,
"description": instructions.description,
"instructions": sliced,
"section_used": section_used,
}
logger.info(
"SkillsTool - get: skill=%s (section=%s, lines=%s-%s) -> content received",
skill_name,
section,
line_start,
line_end,
)
return {"content": out}

logger.info(
"SkillsTool - get: skill=%s -> content received (%d chars)",
skill_name,
len(instructions.instructions),
)
return {
"content": {
"name": instructions.name,
"description": instructions.description,
"instructions": instructions.instructions,
}
}
31 changes: 27 additions & 4 deletions dynamiq/serializers/loaders/yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ def get_updated_node_init_data_with_initialized_nodes(
connection_manager: ConnectionManager | None = None,
init_components: bool = False,
max_workers: int | None = None,
node_id: str | None = None,
):
"""
Get node init data with initialized nodes components recursively (llms, agents, etc)
Expand Down Expand Up @@ -553,6 +554,7 @@ def get_updated_node_init_data_with_initialized_nodes(
connection_manager=connection_manager,
init_components=init_components,
max_workers=max_workers,
node_id=node_id,
)
for param_name, param_data in node_init_data.items():
# TODO: dummy fix, revisit this!
Expand All @@ -566,6 +568,20 @@ def get_updated_node_init_data_with_initialized_nodes(
for param_name_inner, param_data_inner in param_data.items():
if param_name_inner in ("prompt", "schema", "response_format"):
updated_param_data[param_name_inner] = param_data_inner
elif (
param_name_inner == "connection"
and isinstance(param_data_inner, dict)
and param_data_inner.get("type")
and "connections" in str(param_data_inner.get("type", ""))
):
try:
updated_param_data[param_name_inner] = cls.get_inline_connection(
node_id or "unknown", param_data_inner, registry
)
except Exception as e:
raise WorkflowYAMLLoaderException(
f"Invalid inline connection in node '{node_id}': {e}"
) from e
elif isinstance(param_data_inner, (dict, list)):
param_id = None
updated_param_data[param_name_inner] = cls.get_updated_node_init_data_with_initialized_nodes(
Expand All @@ -574,11 +590,17 @@ def get_updated_node_init_data_with_initialized_nodes(
else:
updated_param_data[param_name_inner] = param_data_inner

if cls.is_node_type(updated_param_data.get("type")):
type_str = str(updated_param_data.get("type", ""))
if (
cls.is_node_type(updated_param_data.get("type"))
and "connections" not in type_str
and "skills." not in type_str
):
param_id = updated_param_data.get("id")
updated_param_data = cls.get_nodes_without_depends({param_id: updated_param_data}, **kwargs)[
param_id
]
get_node_kwargs = {k: v for k, v in kwargs.items() if k != "node_id"}
updated_param_data = cls.get_nodes_without_depends(
{param_id: updated_param_data}, **get_node_kwargs
)[param_id]

updated_node_init_data[param_name] = updated_param_data

Expand Down Expand Up @@ -772,6 +794,7 @@ def get_node_without_depends(
registry=registry,
connection_manager=connection_manager,
init_components=init_components,
node_id=node_id,
)

node = node_cls(**node_init_data)
Expand Down
Loading
Loading