Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/zh/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@
- [ ] github action
- [ ] contribution guide
- [ ] ah feature & backtest
- [ ] Look at the Bollinger Bands range with 95% standard deviation.

20 changes: 20 additions & 0 deletions flowllm/config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,26 @@ flow:
description: "user query"
required: true

skill_agent_flow:
flow_content: |
skill_op = SkillAgentOp()
skill_op.ops.load_metadata = LoadSkillMetadataOp()
skill_op.ops.load_skill = LoadSkillOp()
skill_op.ops.read_reference = ReadReferenceFileOp()
skill_op.ops.run_shell = RunShellCommandOp()
skill_op
description: "Automatically uses pre-built Skills relevant to the query when needed."
input_schema:
query:
type: string
description: "query"
required: true
skill_dir:
type: string
description: "skill dir"
required: true


llm:
default:
backend: openai_compatible
Expand Down
8 changes: 8 additions & 0 deletions flowllm/core/context/base_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ def keys(self):
"""
return self._data.keys()

def values(self):
"""Get all values in the context.

Returns:
A view object of all values in the context.
"""
return self._data.values()

def update(self, kwargs: dict):
"""Update context with new key-value pairs.

Expand Down
2 changes: 1 addition & 1 deletion flowllm/core/op/base_async_tool_op.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def output_keys(self) -> str | List[str]:
return output_keys

@property
def output(self) -> str:
def output(self):
"""Convenience accessor for the primary output value.

Raises:
Expand Down
2 changes: 1 addition & 1 deletion flowllm/core/op/base_op.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def __init__(
name: str = "",
async_mode: bool = False,
max_retries: int = 1,
raise_exception: bool = True,
raise_exception: bool = False,
enable_multithread: bool = True,
language: str = "",
prompt_path: str = "",
Expand Down
13 changes: 5 additions & 8 deletions flowllm/core/schema/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,12 @@ class Message(BaseModel):
time_created: str = Field(default_factory=lambda: datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
metadata: dict = Field(default_factory=dict)

def simple_dump(self, add_reason_content: bool = True) -> dict:
def simple_dump(self, add_reasoning: bool = False) -> dict:
"""Convert Message to a simple dictionary format for API serialization."""
result: dict
if self.content:
result = {"role": self.role.value, "content": self.content}
elif add_reason_content and self.reasoning_content:
result = {"role": self.role.value, "content": self.reasoning_content}
else:
result = {"role": self.role.value, "content": ""}
result: dict = {"role": self.role.value, "content": self.content}

if add_reasoning:
result["reasoning_content"] = self.reasoning_content

if self.tool_calls:
result["tool_calls"] = [x.simple_output_dump() for x in self.tool_calls]
Expand Down
9 changes: 6 additions & 3 deletions flowllm/core/service/base_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,22 @@ def run(self):
prints the logo (optional) and suppresses deprecation warnings. Concrete
services should call super().run() before their own startup logic.
"""
flow_names = []
for _, flow in C.flow_dict.items():
assert isinstance(flow, BaseFlow)
if flow.stream:
if self.integrate_stream_flow(flow):
logger.info(f"integrate {flow.name}[stream]")
flow_names.append(flow.name)

elif isinstance(flow, BaseToolFlow):
if self.integrate_tool_flow(flow):
logger.info(f"integrate {flow.name}")
flow_names.append(flow.name)

else:
if self.integrate_flow(flow):
logger.info(f"integrate {flow.name}")
flow_names.append(flow.name)

logger.info(f"integrate {','.join(flow_names)}")

import warnings

Expand Down
4 changes: 3 additions & 1 deletion flowllm/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
- file_tool: File-related operations including editing and searching files
- data: Data-related operations including downloading stock data
- utils: Utility functions for date/time operations and other helpers
- skills: Skill-based operations for managing and executing specialized skills
"""

from . import data, file_tool, utils
from . import data, file_tool, skills, utils

__all__ = [
"data",
"file_tool",
"skills",
"utils",
]
19 changes: 19 additions & 0 deletions flowllm/extensions/skills/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Skills extension module for flowllm.

This module provides operations for managing and executing skills,
including loading skill metadata, reading reference files, and running shell commands.
"""

from .load_skill_metadata_op import LoadSkillMetadataOp
from .load_skill_op import LoadSkillOp
from .read_reference_file_op import ReadReferenceFileOp
from .run_shell_command_op import RunShellCommandOp
from .skill_agent_op import SkillAgentOp

__all__ = [
"LoadSkillMetadataOp",
"LoadSkillOp",
"ReadReferenceFileOp",
"RunShellCommandOp",
"SkillAgentOp",
]
106 changes: 106 additions & 0 deletions flowllm/extensions/skills/load_skill_metadata_op.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Operation for loading skill metadata.

This module provides the LoadSkillMetadataOp class which scans the skills
directory and extracts metadata (name and description) from all SKILL.md files.
"""

from pathlib import Path

from loguru import logger

from ...core.context import C
from ...core.op import BaseAsyncToolOp
from ...core.schema import ToolCall


@C.register_op()
class LoadSkillMetadataOp(BaseAsyncToolOp):
"""Operation for loading metadata from all available skills.

This tool scans the skills directory for SKILL.md files and extracts
their metadata (name and description) from YAML frontmatter.
Returns a JSON string containing a list of skill metadata.
"""

def build_tool_call(self) -> ToolCall:
"""Build the tool call definition for load_skill_metadata.

Returns:
A ToolCall object defining the load_skill_metadata tool.
"""
tool_params = {
"name": "load_skill_metadata",
"description": "Load metadata (name and description) for all available skills from the skills directory.",
"input_schema": {},
}
return ToolCall(**tool_params)

@staticmethod
async def parse_skill_metadata(content: str, path: str) -> dict[str, str] | None:
"""Extract skill metadata (name and description) from SKILL.md content.

Parses YAML frontmatter from SKILL.md files to extract the skill name
and description. The frontmatter should be in the format:
---
name: skill_name
description: skill description
---

Args:
content: The content of the SKILL.md file.
path: The file path (used for logging purposes).

Returns:
A dictionary with 'name' and 'description' keys, or None if
parsing fails or required fields are missing.
"""
parts = content.split("---")
if len(parts) < 3:
logger.warning(f"No YAML frontmatter found in skill from {path}")
return None

frontmatter_text = parts[1].strip()
name = None
description = None

for line in frontmatter_text.split("\n"):
line = line.strip()
if line.startswith("name:"):
name = line.split(":", 1)[1].strip().strip("\"'")
elif line.startswith("description:"):
description = line.split(":", 1)[1].strip().strip("\"'")

if not name or not description:
logger.warning(f"Missing name or description in skill from {path}")
return None

return {
"name": name,
"description": description,
}

async def async_execute(self):
"""Execute the load skill metadata operation.

Scans the skills directory recursively for all SKILL.md files,
extracts their metadata, and returns a JSON string containing
a list of all skill metadata entries.
"""
skill_dir = Path(self.context.skill_dir)
logger.info(f"🔧 Tool called: load_skill_metadata(path={skill_dir})")
skill_files = list(skill_dir.rglob("SKILL.md"))

skill_metadata_dict = {}
for skill_file in skill_files:
content = skill_file.read_text(encoding="utf-8")
metadata = await self.parse_skill_metadata(content, str(skill_file))
if metadata:
skill_dir = skill_file.parent.as_posix()
skill_metadata_dict[metadata["name"]] = {
"description": metadata["description"],
"skill_dir": skill_dir,
}
logger.info(f"✅ Loaded skill {metadata['name']} metadata skill_dir={skill_dir}")

logger.info(f"✅ Loaded {len(skill_metadata_dict)} skill metadata entries")
self.set_output(skill_metadata_dict)
72 changes: 72 additions & 0 deletions flowllm/extensions/skills/load_skill_op.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Operation for loading a specific skill.

This module provides the LoadSkillOp class which loads the full content
of a SKILL.md file from a specified skill directory.
"""

from pathlib import Path

from loguru import logger

from ...core.context import C
from ...core.op import BaseAsyncToolOp
from ...core.schema import ToolCall


@C.register_op()
class LoadSkillOp(BaseAsyncToolOp):
"""Operation for loading a specific skill's instructions.

This tool loads the complete content of a SKILL.md file from a
specified skill directory, including its YAML frontmatter and
instructions.
"""

def build_tool_call(self) -> ToolCall:
"""Build the tool call definition for load_skill.

Returns:
A ToolCall object defining the load_skill tool.
"""
return ToolCall(
**{
"name": "load_skill",
"description": "Load one skill's instructions from the SKILL.md.",
"input_schema": {
"skill_name": {
"type": "string",
"description": "skill name",
"required": True,
},
},
},
)

async def async_execute(self):
"""Execute the load skill operation.

Loads the SKILL.md file from the specified skill directory.
If the skill is not found, sets an error message with available
skills in the output.

The file path is constructed as: {skill_dir}/{skill_name}/SKILL.md
"""
skill_name = self.input_dict["skill_name"]
skill_dir = Path(self.context.skill_dir)
logger.info(f"🔧 Tool called: load_skill(skill_name='{skill_name}')")
skill_path = skill_dir / skill_name / "SKILL.md"

if not skill_path.exists():
available = [d.name for d in (skill_dir / skill_name).iterdir() if d.is_dir() and (d / "SKILL.md").exists()]
logger.exception(f"❌ Skill '{skill_name}' not found")
self.set_output(f"Skill '{skill_name}' not found. Available: {', '.join(available)}")
return

content = skill_path.read_text(encoding="utf-8")
parts = content.split("---")
if len(parts) < 3:
logger.warning(f"No YAML frontmatter found in skill from {skill_path}")
return

logger.info(f"✅ Loaded skill: {skill_name}")
self.set_output(content)
70 changes: 70 additions & 0 deletions flowllm/extensions/skills/read_reference_file_op.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Operation for reading reference files from skills.

This module provides the ReadReferenceFileOp class which allows reading
reference files (e.g., forms.md, reference.md) from skill directories.
"""

from pathlib import Path

from loguru import logger

from ...core.context import C
from ...core.op import BaseAsyncToolOp
from ...core.schema import ToolCall


@C.register_op()
class ReadReferenceFileOp(BaseAsyncToolOp):
"""Operation for reading reference files from a skill directory.

This tool allows reading reference files like forms.md, reference.md,
or ooxml.md from a specific skill's directory.
"""

def build_tool_call(self) -> ToolCall:
"""Build the tool call definition for read_reference_file.

Returns:
A ToolCall object defining the read_reference_file tool.
"""
return ToolCall(
**{
"name": "read_reference_file",
"description": "Read a reference file from a skill (e.g., forms.md, reference.md, ooxml.md)",
"input_schema": {
"skill_name": {
"type": "string",
"description": "skill name",
"required": True,
},
"file_name": {
"type": "string",
"description": "reference file name or file path",
"required": True,
},
},
},
)

async def async_execute(self):
"""Execute the read reference file operation.

Reads a reference file from the specified skill directory.
If the file is not found, sets an error message in the output.

The file path is constructed as: {skill_dir}/{skill_name}/{file_name}
"""
skill_name = self.input_dict["skill_name"]
file_name = self.input_dict["file_name"]
skill_dir = Path(self.context.skill_dir)

logger.info(f"🔧 Tool called: read_reference_file(skill_name='{skill_name}', file_name='{file_name}')")

file_path = skill_dir / skill_name / file_name
if not file_path.exists():
logger.exception(f"❌ File not found: {file_path}")
self.set_output(f"File '{file_name}' not found in skill '{skill_name}'")
return

logger.info(f"✅ Read file: {skill_name}/{file_name}")
self.set_output(file_path.read_text(encoding="utf-8"))
Loading