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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ 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-adk/`](packages/skillos-adk/) — Google ADK analyzer for the
Curator (`ADKCurator` + curator plugin via `google-adk`).

## Development

Expand Down
21 changes: 21 additions & 0 deletions packages/skillos-adk/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.
27 changes: 27 additions & 0 deletions packages/skillos-adk/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "skillos-adk"
version = "0.1.0"
description = "Google ADK 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",
"google-adk>=1.0",
]

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

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

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

import json
from typing import TYPE_CHECKING, Any, Union

from google.adk.agents import LlmAgent
from google.adk.plugins.base_plugin import BasePlugin
from google.adk.runners import InMemoryRunner
from google.genai import types
from skillos_core import Changelog, ConversationHistory, Curator, SkillRepo

from .tools import create_skill_tools

if TYPE_CHECKING:
from google.adk.agents.invocation_context import InvocationContext
from google.adk.models.base_llm import BaseLlm

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.
"""

_APP_NAME = "skillos-adk-curator"
_USER_ID = "skillos-curator"


def _format_history(history: ConversationHistory) -> str:
lines: list[str] = []
for msg in history:
role = msg.get("role", "unknown")
content = msg.get("content", "")

if isinstance(content, str):
if content:
lines.append(f"[{role}] {content}")

elif isinstance(content, list):
for block in content:
if isinstance(block, dict):
if "text" in block:
lines.append(f"[{role}] {block['text']}")
elif "function_call" in block:
fc = block["function_call"]
lines.append(
f"[{role}] tool_call: {fc.get('name', '?')}"
f"({json.dumps(fc.get('args', {}), default=str)})"
)
elif "function_response" in block:
fr = block["function_response"]
lines.append(
f"[{role}] tool_result: "
f"{json.dumps(fr.get('response', ''), default=str)[:500]}"
)
else:
lines.append(f"[{role}] {block}")

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


def _events_to_history(events: list[Any]) -> ConversationHistory:
"""Convert ADK session events into a backend-agnostic ConversationHistory.

Each ADK ``Event`` carries a ``types.Content`` with a role and a list of
parts (text, function_call, function_response). This flattens those into
the dict-of-blocks shape that :func:`_format_history` understands.
"""
history: ConversationHistory = []
for event in events:
content = getattr(event, "content", None)
if content is None:
continue
role = content.role or getattr(event, "author", None) or "unknown"
blocks: list[dict[str, Any]] = []
for part in content.parts or []:
if getattr(part, "text", None):
blocks.append({"text": part.text})
elif getattr(part, "function_call", None):
fc = part.function_call
blocks.append({"function_call": {"name": fc.name, "args": dict(fc.args or {})}})
elif getattr(part, "function_response", None):
fr = part.function_response
blocks.append({"function_response": {"name": fr.name, "response": fr.response}})
if blocks:
history.append({"role": role, "content": blocks})
return history


class _CuratorPlugin(BasePlugin):
"""ADK plugin that runs the curator after each agent invocation.

This is the ADK analogue of a Strands ``HookProvider``: a class that
subscribes to runner lifecycle callbacks. ``after_run_callback`` fires
once the runner finishes a full invocation, at which point the session's
events are handed to the curator.
"""

def __init__(self, curator: ADKCurator, *, name: str = "skillos_curator") -> None:
super().__init__(name=name)
self._curator = curator

async def after_run_callback(self, *, invocation_context: InvocationContext) -> None:
events = list(getattr(invocation_context.session, "events", []) or [])
history = _events_to_history(events)
if history:
await self._curator.curate(history)


class ADKCurator(Curator):
"""Google ADK agent-based Curator that uses tools to mutate a SkillRepo.

The curator builds an :class:`~google.adk.agents.LlmAgent` armed with skill
tools, feeds it the formatted conversation history, and runs it via an
in-memory runner. The agent reasons about what skills to create/update/delete
and calls tools to make those changes; the :class:`Changelog` records what
actually happened.
"""

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

def plugin(self) -> BasePlugin:
"""Return a plugin for use in ``Runner(plugins=[curator.plugin()])``."""
return _CuratorPlugin(self)

async def curate(self, history: ConversationHistory) -> Changelog:
changelog = Changelog()
tools = create_skill_tools(self._repo, changelog=changelog)
agent = LlmAgent(
name="skill_curator",
model=self._model,
instruction=self._system_prompt,
tools=list(tools),
)
runner = InMemoryRunner(agent=agent, app_name=_APP_NAME)
session = await runner.session_service.create_session(app_name=_APP_NAME, user_id=_USER_ID)
message = types.Content(role="user", parts=[types.Part(text=_format_history(history))])
async for _ in runner.run_async(
user_id=_USER_ID, session_id=session.id, new_message=message
):
pass
return changelog
166 changes: 166 additions & 0 deletions packages/skillos-adk/src/skillos_adk/tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
from __future__ import annotations

from typing import Any, Optional

from google.adk.tools import FunctionTool
from skillos_core import Change, ChangeKind, Changelog, SkillRepo


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

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

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

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(),
}

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}

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}

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 [
FunctionTool(func=list_skills),
FunctionTool(func=read_skill),
FunctionTool(func=insert_skill),
FunctionTool(func=update_skill),
FunctionTool(func=delete_skill),
]
Loading
Loading