Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Basically, it is a framework for self-evolving agents.
(`SkillRepo`, `Skill`, `Curator`, `AsyncCurator`). Backend-agnostic via fsspec.
- [`packages/skillos-strands/`](packages/skillos-strands/) — Strands Agents
analyzer for the Curator (Amazon Bedrock via `strands-agents`).
- [`packages/skillos-langchain/`](packages/skillos-langchain/) — LangChain
analyzer for the Curator (`LangChainCurator` plus a callback handler that
curates skills after an agent run, via `langchain`).

## Development

Expand Down
21 changes: 21 additions & 0 deletions packages/skillos-langchain/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Shea Hawkins

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
28 changes: 28 additions & 0 deletions packages/skillos-langchain/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "skillos-langchain"
version = "0.1.0"
description = "LangChain plugin for SkillOS Curator"
requires-python = ">=3.10"
license = { file = "LICENSE" }
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"skillos-core",
"langchain>=1.0",
"langchain-core>=1.0",
]

[tool.uv.sources]
skillos-core = { workspace = true }

[tool.setuptools.packages.find]
where = ["src"]
include = ["skillos_langchain*"]
4 changes: 4 additions & 0 deletions packages/skillos-langchain/src/skillos_langchain/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .curator import LangChainCurator
from .tools import create_skill_tools

__all__ = ["LangChainCurator", "create_skill_tools"]
161 changes: 161 additions & 0 deletions packages/skillos-langchain/src/skillos_langchain/curator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
from __future__ import annotations

import json
from typing import Any, Optional
from uuid import UUID

from langchain.agents import create_agent
from langchain_core.callbacks import AsyncCallbackHandler
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import BaseMessage
from skillos_core import Changelog, ConversationHistory, Curator, SkillRepo

from .tools import create_skill_tools

SYSTEM_PROMPT = """\
You are a skill curator for SkillOS. You receive conversation history from \
an agent's run and use your tools to manage a skill repository.

Analyze the conversation and determine what skills should be created, updated, \
or deleted. Use list_skills and read_skill to understand the current state, then \
insert_skill, update_skill, or delete_skill to make changes.

Rules for skills:
- name: lowercase alphanumeric with hyphens, max 64 chars.
- description: concise, max 1024 chars. Include what and when to use.
- body: markdown instructions for the agent.
- If no changes are warranted, do nothing.
"""

# LangChain message types map to the role labels used in the formatted history.
_ROLE_BY_TYPE = {
"human": "user",
"ai": "assistant",
"system": "system",
"tool": "tool",
}


def _format_base_message(msg: BaseMessage) -> list[str]:
role = _ROLE_BY_TYPE.get(msg.type, msg.type)
lines: list[str] = []

if msg.type == "tool":
text = msg.content if isinstance(msg.content, str) else json.dumps(msg.content, default=str)
lines.append(f"[{role}] tool_result: {text[:500]}")
return lines

content = msg.content
if isinstance(content, str):
if content:
lines.append(f"[{role}] {content}")
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and "text" in block:
lines.append(f"[{role}] {block['text']}")
else:
lines.append(f"[{role}] {block}")

for tc in getattr(msg, "tool_calls", None) or []:
name = tc.get("name", "?")
args = tc.get("args", {})
lines.append(f"[{role}] tool_call: {name}({json.dumps(args, default=str)})")

return lines


def _format_dict_message(msg: dict[str, Any]) -> list[str]:
role = msg.get("role", "unknown")
content = msg.get("content", "")
lines: list[str] = []

if isinstance(content, str):
if content:
lines.append(f"[{role}] {content}")
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and "text" in block:
lines.append(f"[{role}] {block['text']}")
else:
lines.append(f"[{role}] {block}")

for tc in msg.get("tool_calls", []) or []:
fn = tc.get("function", {})
lines.append(f"[{role}] tool_call: {fn.get('name', '?')}({fn.get('arguments', '')})")

return lines


def _format_history(history: ConversationHistory) -> str:
lines: list[str] = []
for msg in history:
if isinstance(msg, BaseMessage):
lines.extend(_format_base_message(msg))
elif isinstance(msg, dict):
lines.extend(_format_dict_message(msg))
else:
lines.append(f"[unknown] {msg}")

return "\n".join(lines) if lines else "(empty conversation)"


class _CuratorCallbackHandler(AsyncCallbackHandler):
"""LangChain callback that curates skills after an agent run finishes.

This is the LangChain analogue of a Strands ``HookProvider``: it hooks
into the agent lifecycle and fires once, when the root run completes,
handing the resulting conversation to the curator.
"""

def __init__(self, curator: LangChainCurator) -> None:
self._curator = curator

async def on_chain_end(
self,
outputs: dict[str, Any],
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
) -> None:
# Only act on the root run; nested chains have a parent.
if parent_run_id is not None:
return
messages = outputs.get("messages") if isinstance(outputs, dict) else None
if messages:
await self._curator.curate(messages)


class LangChainCurator(Curator):
"""LangChain agent-based Curator that uses tools to mutate a SkillRepo.

The agent receives formatted conversation history, reasons about what
skills to create/update/delete, and calls tools to make those changes.
The Changelog records what actually happened.
"""

def __init__(
self,
repo: SkillRepo,
*,
model: BaseChatModel,
system_prompt: str = SYSTEM_PROMPT,
) -> None:
self._repo = repo
self._model = model
self._system_prompt = system_prompt

def callback(self) -> AsyncCallbackHandler:
"""Return a callback handler for use in ``config={"callbacks": [curator.callback()]}``.

This is the LangChain equivalent of a Strands hook provider.
"""
return _CuratorCallbackHandler(self)

async def curate(self, history: ConversationHistory) -> Changelog:
changelog = Changelog()
tools = create_skill_tools(self._repo, changelog=changelog)
agent = create_agent(self._model, tools, system_prompt=self._system_prompt)
prompt = _format_history(history)
await agent.ainvoke({"messages": [("user", prompt)]})
return changelog
162 changes: 162 additions & 0 deletions packages/skillos-langchain/src/skillos_langchain/tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
from __future__ import annotations

from typing import Any, Optional

from langchain_core.tools import BaseTool, tool
from skillos_core import Change, ChangeKind, Changelog, SkillRepo


def create_skill_tools(repo: SkillRepo, *, changelog: Optional[Changelog] = None) -> list[BaseTool]:
"""Create LangChain tools for interacting with a SkillRepo.

Returns a list of tools suitable for passing to ``create_agent(model, tools)``.
When ``changelog`` is provided, insert/update/delete operations also
record each mutation as a :class:`Change` with ``applied`` status.
"""

@tool
def list_skills() -> list[str]:
"""List all skill names in the repository."""
return repo.list_skills()

@tool
def read_skill(name: str) -> dict[str, Any]:
"""Read a skill's metadata and body by name."""
skill = repo.read(name)
return {
"name": skill.name,
"description": skill.description,
"body": skill.body,
"metadata": skill.metadata,
"resources": skill.list_resources(),
}

@tool
def insert_skill(
name: str,
description: str,
body: str,
license: str = "MIT",
allowed_tools: Optional[list[str]] = None,
compatibility: Optional[str] = None,
metadata: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""Insert a new skill into the repository.

name must be lowercase alphanumeric with hyphens, max 64 chars.
description max 1024 chars. body is markdown. license must be a valid SPDX identifier.
"""
kwargs: dict[str, Any] = {"license": license}
if allowed_tools is not None:
kwargs["allowed_tools"] = allowed_tools
if compatibility is not None:
kwargs["compatibility"] = compatibility
if metadata is not None:
kwargs["metadata"] = metadata

change = (
Change(
kind=ChangeKind.INSERT,
name=name,
description=description,
body=body,
license=license,
allowed_tools=allowed_tools,
compatibility=compatibility,
metadata=metadata,
)
if changelog is not None
else None
)
try:
repo.insert(name, description, body, **kwargs)
if change:
change.applied = True
except Exception as e:
if change:
change.applied = False
change.error = str(e)
raise
finally:
if change and changelog is not None:
changelog.changes.append(change)

return {"name": name, "applied": True, "error": None}

@tool
def update_skill(
name: str,
description: Optional[str] = None,
body: Optional[str] = None,
license: Optional[str] = None,
allowed_tools: Optional[list[str]] = None,
compatibility: Optional[str] = None,
metadata: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""Update an existing skill's body and/or frontmatter fields.

Only supply the fields you want to change; omitted fields are left as-is.
"""
kwargs: dict[str, Any] = {}
if description is not None:
kwargs["description"] = description
if body is not None:
kwargs["body"] = body
if license is not None:
kwargs["license"] = license
if allowed_tools is not None:
kwargs["allowed_tools"] = allowed_tools
if compatibility is not None:
kwargs["compatibility"] = compatibility
if metadata is not None:
kwargs["metadata"] = metadata

change = (
Change(
kind=ChangeKind.UPDATE,
name=name,
description=description,
body=body,
license=license,
allowed_tools=allowed_tools,
compatibility=compatibility,
metadata=metadata,
)
if changelog is not None
else None
)
try:
repo.update(name, **kwargs)
if change:
change.applied = True
except Exception as e:
if change:
change.applied = False
change.error = str(e)
raise
finally:
if change and changelog is not None:
changelog.changes.append(change)

return {"name": name, "applied": True, "error": None}

@tool
def delete_skill(name: str) -> dict[str, Any]:
"""Delete a skill and all its bundled resources."""
change = Change(kind=ChangeKind.DELETE, name=name) if changelog is not None else None
try:
repo.delete(name)
if change:
change.applied = True
except Exception as e:
if change:
change.applied = False
change.error = str(e)
raise
finally:
if change and changelog is not None:
changelog.changes.append(change)

return {"name": name, "applied": True, "error": None}

return [list_skills, read_skill, insert_skill, update_skill, delete_skill]
Loading
Loading