diff --git a/README.md b/README.md index 94bd370..fa56f6f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,86 @@ # skillos -Implementation of Google's [SkillOS](https://arxiv.org/abs/2605.06614). +Implementation of Google's [SkillOS](https://arxiv.org/abs/2605.06614) — a framework for self-evolving agents. -Basically, it is a framework for self-evolving agents. +## Architecture -## Packages +SkillOS is split into a backend-agnostic core and SDK-specific plugins: -- [`packages/skillos-core/`](packages/skillos-core/) — core abstractions - (`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`). +| Package | Install | Description | +|---------|---------|-------------| +| [`skillos-core`](packages/skillos-core/) | `pip install skillos-core` | Core abstractions: `SkillRepo`, `Skill`, `Curator`, `Changelog`. Backend-agnostic via fsspec. | +| [`skillos-strands`](packages/skillos-strands/) | `pip install skillos-strands` | Curator backed by [Strands Agents](https://github.com/strands-agents/sdk-python) (Amazon Bedrock). | +| [`skillos-adk`](packages/skillos-adk/) | `pip install skillos-adk` | Curator backed by [Google ADK](https://google.github.io/adk-docs/) (Gemini). | +| [`skillos-langchain`](packages/skillos-langchain/) | `pip install skillos-langchain` | Curator backed by [LangChain](https://python.langchain.com/) (any LLM). | + +The core package defines the `Curator` interface and `SkillRepo` primitives. SDK packages implement the curator for their framework — and nothing else. You pick the one that matches your agent runtime. + +## Quick start + +Choose the package for your agent framework: + +=== "Strands" + + ```bash + pip install skillos-strands + ``` + + ```python + from skillos_core import SkillRepo + from skillos_strands import StrandsCurator + from strands import Agent + from strands.models import BedrockModel + + repo = SkillRepo("./my-skills") + model = BedrockModel(model_id="us.amazon.nova-pro-v1:0") + curator = StrandsCurator(repo, model=model) + + agent = Agent(model=model, hooks=[curator.hook()]) + agent("Summarize this document for me.") + # → skills are curated automatically after each invocation + ``` + +=== "Google ADK" + + ```bash + pip install skillos-adk + ``` + + ```python + from skillos_core import SkillRepo + from skillos_adk import ADKCurator + from google.adk.agents import LlmAgent + from google.adk.runners import Runner + + repo = SkillRepo("./my-skills") + curator = ADKCurator(repo, model="gemini-2.0-flash") + + agent = LlmAgent(name="my-agent", model="gemini-2.0-flash") + runner = Runner(agent=agent, plugins=[curator.plugin()]) + # → skills are curated automatically after each run + ``` + +=== "LangChain" + + ```bash + pip install skillos-langchain + ``` + + ```python + from skillos_core import SkillRepo + from skillos_langchain import LangChainCurator + from langchain_aws import ChatBedrock + + repo = SkillRepo("./my-skills") + llm = ChatBedrock(model_id="us.amazon.nova-pro-v1:0") + curator = LangChainCurator(repo, llm=llm) + + await agent.ainvoke( + {"input": "Summarize this document."}, + config={"callbacks": [curator.callback()]}, + ) + # → skills are curated automatically when the chain completes + ``` ## Development @@ -22,7 +93,7 @@ uv sync # create .venv and install all workspace packages uv run pytest # run the full test suite ``` -To work on a single package, run pytest scoped to it: +To work on a single package: ``` uv run pytest packages/skillos-core diff --git a/docs/api/skillos-adk.md b/docs/api/skillos-adk.md new file mode 100644 index 0000000..0859840 --- /dev/null +++ b/docs/api/skillos-adk.md @@ -0,0 +1,77 @@ +# skillos-adk API Reference + +`skillos-adk` wraps a [Google ADK](https://google.github.io/adk-docs/) `LlmAgent` as a [`Curator`](skillos-core.md#curator). It fires automatically after each runner invocation via an ADK plugin, or can be called manually. + +```bash +pip install skillos-adk +``` + +--- + +## ADKCurator + +```python +from skillos_adk import ADKCurator +``` + +A [`Curator`](skillos-core.md#curator) backed by a Google ADK `LlmAgent`. + +### `ADKCurator(repo, *, model, system_prompt=...)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `repo` | `SkillRepo` | The skill repository to curate. | +| `model` | `str \| BaseLlm` | Model name (e.g. `"gemini-2.0-flash"`) or an ADK `BaseLlm` instance. | +| `system_prompt` | `str` | Override the default curator system prompt. | + +### `plugin() -> BasePlugin` + +Return a `BasePlugin` for use in `Runner(plugins=[curator.plugin()])`. The plugin fires `curate()` automatically after every runner invocation via `after_run_callback`. + +```python +from skillos_adk import ADKCurator +from google.adk.agents import LlmAgent +from google.adk.runners import InMemoryRunner + +repo = SkillRepo("./my-skills") +curator = ADKCurator(repo, model="gemini-2.0-flash") + +agent = LlmAgent(name="my-agent", model="gemini-2.0-flash") +runner = InMemoryRunner(agent=agent, app_name="my-app", plugins=[curator.plugin()]) +``` + +### `curate(history: ConversationHistory) -> Changelog` + +Analyse a [`ConversationHistory`](skillos-core.md#conversationhistory) and mutate the repo. Returns a [`Changelog`](skillos-core.md#changelog) describing what changed. + +Use this directly when you want to trigger curation from a specific point in your runner loop: + +```python +history = _events_to_history(session.events) +changelog = await curator.curate(history) +print(f"{len(changelog.applied)} skill(s) updated") +``` + +--- + +## create_skill_tools + +```python +from skillos_adk import create_skill_tools +``` + +### `create_skill_tools(repo, *, changelog=None) -> list[FunctionTool]` + +Create the five ADK `FunctionTool` wrappers for interacting with a `SkillRepo`. Returned tools are suitable for `LlmAgent(tools=...)`. + +| Tool | Description | +|------|-------------| +| `list_skills` | Return all skill names. | +| `read_skill(name)` | Return a skill's name, description, body, metadata, and resource list. | +| `insert_skill(name, description, body, ...)` | Create a new skill. | +| `update_skill(name, ...)` | Partially update an existing skill. | +| `delete_skill(name)` | Delete a skill and its resources. | + +When `changelog` is provided, every mutation is recorded as a [`Change`](skillos-core.md#change). + +`ADKCurator` calls `create_skill_tools` internally; you only need this directly if you are building a custom curator or want skill tools in your own ADK agent. diff --git a/docs/api/skillos-core.md b/docs/api/skillos-core.md index b40ddda..19cfd4c 100644 --- a/docs/api/skillos-core.md +++ b/docs/api/skillos-core.md @@ -86,6 +86,72 @@ Read a bundled resource file by relative path. --- +## Curator + +```python +from skillos_core import Curator +``` + +Abstract base class for SDK-specific curator implementations. + +### `curate(history: ConversationHistory) -> Changelog` + +Analyse the conversation and make changes to the `SkillRepo`. Returns a `Changelog` describing what was created, updated, or deleted. Concrete implementations are provided by `skillos-strands`, `skillos-adk`, and `skillos-langchain`. + +--- + +## ConversationHistory + +```python +from skillos_core import ConversationHistory, Message +``` + +`ConversationHistory = list[Message]` where `Message = dict[str, Any]`. SDK packages translate their native message types into this format before passing them to `Curator.curate()`. + +--- + +## Changelog + +```python +from skillos_core import Changelog, Change, ChangeKind +``` + +### `Changelog` + +A record of what the curator did in one pass. + +| Attribute / Property | Type | Description | +|----------------------|------|-------------| +| `changes` | `list[Change]` | All changes attempted, in order. | +| `applied` | `list[Change]` | Changes that succeeded. | +| `failed` | `list[Change]` | Changes that raised an exception. | + +### `Change` + +A single attempted skill mutation. + +| Field | Type | Description | +|-------|------|-------------| +| `kind` | `ChangeKind` | `INSERT`, `UPDATE`, or `DELETE`. | +| `name` | `str` | Name of the skill. | +| `applied` | `bool` | `True` if the mutation succeeded. | +| `error` | `str \| None` | Error message if `applied` is `False`. | +| `description` | `str \| None` | New description, if supplied. | +| `body` | `str \| None` | New body, if supplied. | +| `license` | `str \| None` | New license, if supplied. | +| `allowed_tools` | `list[str] \| None` | New tool list, if supplied. | + +### `ChangeKind` + +```python +class ChangeKind(str, Enum): + INSERT = "insert" + UPDATE = "update" + DELETE = "delete" +``` + +--- + ## License ```python diff --git a/docs/api/skillos-strands.md b/docs/api/skillos-strands.md new file mode 100644 index 0000000..ed64edb --- /dev/null +++ b/docs/api/skillos-strands.md @@ -0,0 +1,78 @@ +# skillos-strands API Reference + +`skillos-strands` wraps a [Strands Agents](https://github.com/strands-agents/sdk-python) `Agent` as a [`Curator`](skillos-core.md#curator). It fires automatically after each invocation via the Strands hook system, or can be called manually. + +```bash +pip install skillos-strands +``` + +--- + +## StrandsCurator + +```python +from skillos_strands import StrandsCurator +``` + +A [`Curator`](skillos-core.md#curator) backed by a Strands `Agent`. + +### `StrandsCurator(repo, *, model, system_prompt=...)` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `repo` | `SkillRepo` | The skill repository to curate. | +| `model` | `strands.models.Model` | The Strands model to use for the curator agent. | +| `system_prompt` | `str` | Override the default curator system prompt. | + +### `hook() -> HookProvider` + +Return a `HookProvider` for use in `Agent(hooks=[curator.hook()])`. The hook fires `curate()` automatically after every agent invocation. + +```python +from skillos_strands import StrandsCurator +from strands import Agent +from strands.models import BedrockModel + +repo = SkillRepo("./my-skills") +model = BedrockModel(model_id="us.amazon.nova-pro-v1:0") +curator = StrandsCurator(repo, model=model) + +agent = Agent(model=model, hooks=[curator.hook()]) +agent("Do something useful.") +``` + +### `curate(history: ConversationHistory) -> Changelog` + +Analyse a [`ConversationHistory`](skillos-core.md#conversationhistory) and mutate the repo. Returns a [`Changelog`](skillos-core.md#changelog) describing what changed. + +Use this directly when you manage the agent lifecycle yourself and want to trigger curation at a specific point: + +```python +result = agent("Do something.") +changelog = await curator.curate(agent.messages) +print(f"{len(changelog.applied)} skill(s) updated") +``` + +--- + +## create_skill_tools + +```python +from skillos_strands import create_skill_tools +``` + +### `create_skill_tools(repo, *, changelog=None) -> list[DecoratedFunctionTool]` + +Create the five Strands `@tool` functions for interacting with a `SkillRepo`. Returned tools are suitable for `Agent(tools=...)`. + +| Tool | Description | +|------|-------------| +| `list_skills` | Return all skill names. | +| `read_skill(name)` | Return a skill's name, description, body, metadata, and resource list. | +| `insert_skill(name, description, body, ...)` | Create a new skill. | +| `update_skill(name, ...)` | Partially update an existing skill. | +| `delete_skill(name)` | Delete a skill and its resources. | + +When `changelog` is provided, every mutation is recorded as a [`Change`](skillos-core.md#change). + +`StrandsCurator` calls `create_skill_tools` internally; you only need this directly if you are building a custom curator or want skill tools in your own agent. diff --git a/docs/getting-started.md b/docs/getting-started.md index 0f76b1c..54b3e89 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,31 +1,172 @@ # Getting Started -## Installation +SkillOS has one core library and three SDK integrations. Think of them as different *languages* for the same idea: pick the one that matches your agent framework. -```bash -pip install skillos-core -``` +## Step 1 — Install -Or with [uv](https://docs.astral.sh/uv/): +=== "Strands" -```bash -uv add skillos-core -``` + ```bash + pip install skillos-strands + # or + uv add skillos-strands + ``` + + Brings in `skillos-core` automatically. Requires Python 3.10+. + +=== "Google ADK" + + ```bash + pip install skillos-adk + # or + uv add skillos-adk + ``` + + Brings in `skillos-core` automatically. Requires Python 3.10+. -## Create a skill repository +=== "LangChain" -A skill repository is any directory (or remote path) where each subdirectory contains a `SKILL.md` file. + ```bash + pip install skillos-langchain + # or + uv add skillos-langchain + ``` + + Brings in `skillos-core` automatically. Requires Python 3.10+. + +## Step 2 — Create a skill repository + +A skill repository is any directory where each subdirectory contains a `SKILL.md` file. ``` my-skills/ -├── hello-world/ +├── summarize/ │ └── SKILL.md └── code-review/ ├── SKILL.md - └── prompt.txt + └── examples.txt +``` + +You can create one with `SkillRepo.insert()` or write files by hand. The format is a YAML frontmatter block followed by a Markdown body: + +```markdown +--- +description: Summarize a document into bullet points. +license: MIT +allowed_tools: + - Read +--- + +# Summarize + +Given a document, produce a concise bulleted summary. ``` -## Open and read skills +## Step 3 — Wire up the curator + +The curator runs after each agent invocation and decides what skills to create, update, or delete based on what happened in the conversation. + +=== "Strands" + + ```python + from skillos_core import SkillRepo + from skillos_strands import StrandsCurator + from strands import Agent + from strands.models import BedrockModel + + repo = SkillRepo("./my-skills") + model = BedrockModel(model_id="us.amazon.nova-pro-v1:0") + curator = StrandsCurator(repo, model=model) + + # Hook-based integration (recommended) + agent = Agent( + model=model, + system_prompt="You are a helpful assistant.", + hooks=[curator.hook()], + ) + + agent("Summarize the quarterly report.") + # After the invocation completes, the curator analyses the conversation + # and updates the skill repo automatically. + ``` + + You can also call the curator manually if you manage the agent lifecycle yourself: + + ```python + changelog = await curator.curate(agent.messages) + print(f"Applied: {len(changelog.applied)}, Failed: {len(changelog.failed)}") + ``` + +=== "Google ADK" + + ```python + from skillos_core import SkillRepo + from skillos_adk import ADKCurator + from google.adk.agents import LlmAgent + from google.adk.runners import Runner, InMemoryRunner + from google.genai import types + + repo = SkillRepo("./my-skills") + curator = ADKCurator(repo, model="gemini-2.0-flash") + + agent = LlmAgent( + name="my-agent", + model="gemini-2.0-flash", + instruction="You are a helpful assistant.", + ) + + # Plugin-based integration (recommended) + runner = InMemoryRunner( + agent=agent, + app_name="my-app", + plugins=[curator.plugin()], + ) + + session = await runner.session_service.create_session( + app_name="my-app", user_id="user-1" + ) + message = types.Content( + role="user", + parts=[types.Part(text="Summarize the quarterly report.")], + ) + async for event in runner.run_async( + user_id="user-1", session_id=session.id, new_message=message + ): + if event.content: + print(event.content) + # After the run completes, the curator plugin fires and updates the skill repo. + ``` + +=== "LangChain" + + ```python + from skillos_core import SkillRepo + from skillos_langchain import LangChainCurator + from langchain_aws import ChatBedrock + from langchain.agents import create_tool_calling_agent, AgentExecutor + from langchain_core.prompts import ChatPromptTemplate + + repo = SkillRepo("./my-skills") + llm = ChatBedrock(model_id="us.amazon.nova-pro-v1:0") + curator = LangChainCurator(repo, llm=llm) + + prompt = ChatPromptTemplate.from_messages([ + ("system", "You are a helpful assistant."), + ("human", "{input}"), + ("placeholder", "{agent_scratchpad}"), + ]) + agent = create_tool_calling_agent(llm, tools=[], prompt=prompt) + executor = AgentExecutor(agent=agent, tools=[]) + + # Callback-based integration (recommended) + await executor.ainvoke( + {"input": "Summarize the quarterly report."}, + config={"callbacks": [curator.callback()]}, + ) + # When the root chain completes, the curator fires and updates the skill repo. + ``` + +## Step 4 — Inspect the skill repository ```python from skillos_core import SkillRepo @@ -33,41 +174,21 @@ from skillos_core import SkillRepo repo = SkillRepo("./my-skills") # list skill names -print(repo.list_skills()) # ['code-review', 'hello-world'] +print(repo.list_skills()) # ['code-review', 'summarize'] # read a specific skill -skill = repo.read("hello-world") +skill = repo.read("summarize") print(skill.description) print(skill.body) -``` -## Write skills - -```python -repo.insert( - name="summarize", - description="Summarize a document into bullet points.", - body="# Summarize\n\nGiven a document, produce a concise summary.", - license="MIT", - allowed_tools=["Read", "Write"], -) -``` - -Update an existing skill: - -```python -repo.update("summarize", description="Summarize any document concisely.") -``` - -Delete a skill: - -```python -repo.delete("summarize") +# iterate +for skill in repo: + print(f"{skill.name}: {skill.description}") ``` ## Remote backends -Because SkillOS uses fsspec, any supported protocol works: +Because `skillos-core` uses [fsspec](https://filesystem-spec.readthedocs.io/), any supported protocol works without changing your agent code: ```python # S3 @@ -79,3 +200,22 @@ repo = SkillRepo("gs://my-bucket/skills") # In-memory (useful for tests) repo = SkillRepo("memory://test-repo") ``` + +## Write operations + +```python +# Insert +repo.insert( + name="summarize", + description="Summarize a document into bullet points.", + body="# Summarize\n\nGiven a document, produce a concise summary.", + license="MIT", + allowed_tools=["Read"], +) + +# Partial update +repo.update("summarize", description="Summarize any document concisely.") + +# Delete +repo.delete("summarize") +``` diff --git a/docs/index.md b/docs/index.md index cef6b22..3aff1fd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,29 @@ # SkillOS -SkillOS is a backend-agnostic skill repository format built on [fsspec](https://filesystem-spec.readthedocs.io/). It lets you store, discover, and manage agent skills — locally, on S3, GCS, Azure, or any filesystem fsspec supports. +SkillOS is an implementation of the [SkillOS paper](https://arxiv.org/abs/2605.06614): a framework where agents maintain a *skill repository* — a library of Markdown-formatted instructions they can read before, and curate after, each run. Over time the agent's skills reflect what actually works. -## Packages +## skillos-core -| Package | Description | -|---------|-------------| -| [skillos-core](api/skillos-core.md) | Core abstractions: `SkillRepo`, `Skill`, `License`. | +[`skillos-core`](api/skillos-core.md) provides the backend-agnostic building blocks. It never depends on an agent framework. + +| Type | Description | +|------|-------------| +| [`SkillRepo`](api/skillos-core.md#skillrepo) | Opens and manages a skill repository on any fsspec filesystem (local, S3, GCS, memory, …). | +| [`Skill`](api/skillos-core.md#skill) | A parsed skill: name, frontmatter metadata, and Markdown body. | +| [`Curator`](api/skillos-core.md#curator) | Abstract base class for SDK-specific curator implementations. | +| [`Changelog`](api/skillos-core.md#changelog) | Record of what the curator created, updated, or deleted in one pass. | + +## SDK integrations + +Each integration wraps `SkillRepo` tools for a specific agent framework. Install only the one that matches your runtime. + +| Package | Framework | Curator class | Hook pattern | +|---------|-----------|---------------|--------------| +| [`skillos-strands`](api/skillos-strands.md) | Strands Agents (Amazon Bedrock) | `StrandsCurator` | `Agent(hooks=[curator.hook()])` | +| [`skillos-adk`](api/skillos-adk.md) | Google ADK (Gemini) | `ADKCurator` | `Runner(plugins=[curator.plugin()])` | +| `skillos-langchain` | LangChain (any LLM) | `LangChainCurator` | `agent.ainvoke(…, config={"callbacks": [curator.callback()]})` | + +See [Getting Started](getting-started.md) to choose a language and wire up the curator in minutes. ## Quick example diff --git a/mkdocs.yml b/mkdocs.yml index 5e8ca73..4977d48 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -20,7 +20,9 @@ theme: name: Switch to light mode features: - content.code.copy + - content.tabs.link - navigation.sections + - navigation.tabs markdown_extensions: - admonition @@ -28,6 +30,8 @@ markdown_extensions: anchor_linenums: true - pymdownx.superfences - pymdownx.details + - pymdownx.tabbed: + alternate_style: true - toc: permalink: true @@ -36,3 +40,5 @@ nav: - Getting Started: getting-started.md - API Reference: - skillos-core: api/skillos-core.md + - skillos-strands: api/skillos-strands.md + - skillos-adk: api/skillos-adk.md diff --git a/packages/skillos-adk/LICENSE b/packages/skillos-adk/LICENSE new file mode 100644 index 0000000..3fff31c --- /dev/null +++ b/packages/skillos-adk/LICENSE @@ -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. diff --git a/packages/skillos-adk/pyproject.toml b/packages/skillos-adk/pyproject.toml new file mode 100644 index 0000000..11fb7c7 --- /dev/null +++ b/packages/skillos-adk/pyproject.toml @@ -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*"] diff --git a/packages/skillos-adk/src/skillos_adk/__init__.py b/packages/skillos-adk/src/skillos_adk/__init__.py new file mode 100644 index 0000000..d687133 --- /dev/null +++ b/packages/skillos-adk/src/skillos_adk/__init__.py @@ -0,0 +1,4 @@ +from .curator import ADKCurator +from .tools import create_skill_tools + +__all__ = ["ADKCurator", "create_skill_tools"] diff --git a/packages/skillos-adk/src/skillos_adk/curator.py b/packages/skillos-adk/src/skillos_adk/curator.py new file mode 100644 index 0000000..97e9ad3 --- /dev/null +++ b/packages/skillos-adk/src/skillos_adk/curator.py @@ -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 diff --git a/packages/skillos-adk/src/skillos_adk/tools.py b/packages/skillos-adk/src/skillos_adk/tools.py new file mode 100644 index 0000000..d4bcdf1 --- /dev/null +++ b/packages/skillos-adk/src/skillos_adk/tools.py @@ -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), + ] diff --git a/packages/skillos-adk/tests/test_curator.py b/packages/skillos-adk/tests/test_curator.py new file mode 100644 index 0000000..c48732a --- /dev/null +++ b/packages/skillos-adk/tests/test_curator.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from google.genai import types +from skillos_adk import ADKCurator, create_skill_tools +from skillos_adk.curator import _events_to_history, _format_history +from skillos_core import ChangeKind, Changelog, ConversationHistory, SkillRepo + + +@pytest.fixture +def repo(tmp_path: Path) -> SkillRepo: + return SkillRepo(str(tmp_path)) + + +@pytest.fixture +def mock_model() -> str: + # ADK accepts a model id string or a BaseLlm instance; a string keeps the + # curator from touching the network when the runner is mocked. + return "gemini-2.0-flash" + + +def _sample_history() -> ConversationHistory: + return [ + {"role": "user", "content": [{"text": "Summarize this PDF."}]}, + { + "role": "model", + "content": [ + {"text": "I'll read the PDF."}, + {"function_call": {"name": "read_file", "args": {"path": "doc.pdf"}}}, + ], + }, + { + "role": "user", + "content": [ + {"function_response": {"name": "read_file", "response": {"text": "PDF text here"}}}, + ], + }, + {"role": "model", "content": [{"text": "Here is your summary."}]}, + ] + + +def _make_event(role: str, parts: list[types.Part], author: str = "") -> SimpleNamespace: + """Build a minimal stand-in for an ADK Event with a ``content`` attribute.""" + return SimpleNamespace(content=types.Content(role=role, parts=parts), author=author or role) + + +# --- _format_history --------------------------------------------------------- + + +def test_format_history_renders_messages() -> None: + text = _format_history(_sample_history()) + assert "[user] Summarize this PDF." in text + assert "tool_call: read_file" in text + assert "tool_result:" in text + assert "PDF text here" in text + assert "[model] Here is your summary." in text + + +def test_format_history_string_content() -> None: + history: ConversationHistory = [ + {"role": "user", "content": "hello"}, + {"role": "model", "content": "hi there"}, + ] + text = _format_history(history) + assert "[user] hello" in text + assert "[model] hi there" in text + + +def test_format_history_empty() -> None: + assert _format_history([]) == "(empty conversation)" + + +# --- _events_to_history ------------------------------------------------------ + + +def test_events_to_history_extracts_text_and_calls() -> None: + events = [ + _make_event("user", [types.Part(text="Extract text from invoice.pdf")]), + _make_event( + "model", + [ + types.Part(text="Reading the PDF."), + types.Part( + function_call=types.FunctionCall(name="read_pdf", args={"path": "invoice.pdf"}) + ), + ], + ), + _make_event( + "user", + [ + types.Part( + function_response=types.FunctionResponse( + name="read_pdf", response={"text": "Invoice #1234"} + ) + ) + ], + ), + ] + history = _events_to_history(events) + assert history[0] == {"role": "user", "content": [{"text": "Extract text from invoice.pdf"}]} + assert {"text": "Reading the PDF."} in history[1]["content"] + assert {"function_call": {"name": "read_pdf", "args": {"path": "invoice.pdf"}}} in history[1][ + "content" + ] + assert history[2]["content"][0]["function_response"]["name"] == "read_pdf" + + +def test_events_to_history_skips_empty_content() -> None: + events = [ + SimpleNamespace(content=None, author="model"), + _make_event("user", [types.Part(text="hi")]), + ] + history = _events_to_history(events) + assert len(history) == 1 + assert history[0]["content"] == [{"text": "hi"}] + + +def test_format_after_events_to_history_roundtrip() -> None: + events = [ + _make_event("user", [types.Part(text="Do a thing")]), + _make_event( + "model", + [types.Part(function_call=types.FunctionCall(name="act", args={"k": "v"}))], + ), + ] + text = _format_history(_events_to_history(events)) + assert "[user] Do a thing" in text + assert "tool_call: act" in text + + +# --- recording tools --------------------------------------------------------- + + +def test_recording_tools_insert(repo: SkillRepo) -> None: + changelog = Changelog() + tools = {t.name: t.func for t in create_skill_tools(repo, changelog=changelog)} + + result = tools["insert_skill"](name="hello", description="A greeting.", body="# Hello\n") + + assert result["applied"] is True + assert result["error"] is None + assert "hello" in repo + assert len(changelog.changes) == 1 + assert changelog.changes[0].kind is ChangeKind.INSERT + assert changelog.changes[0].applied is True + + +def test_recording_tools_insert_failure(repo: SkillRepo) -> None: + changelog = Changelog() + tools = {t.name: t.func for t in create_skill_tools(repo, changelog=changelog)} + + tools["insert_skill"](name="hello", description="desc", body="body\n") + with pytest.raises(FileExistsError): + tools["insert_skill"](name="hello", description="desc", body="body\n") + + assert len(changelog.changes) == 2 + assert changelog.applied == [changelog.changes[0]] + assert changelog.failed == [changelog.changes[1]] + + +def test_recording_tools_update(repo: SkillRepo) -> None: + repo.insert("hello", "v1", "body\n") + changelog = Changelog() + tools = {t.name: t.func for t in create_skill_tools(repo, changelog=changelog)} + + result = tools["update_skill"](name="hello", description="v2") + + assert result["applied"] is True + assert repo.read("hello").description == "v2" + assert changelog.changes[0].kind is ChangeKind.UPDATE + + +def test_recording_tools_delete(repo: SkillRepo) -> None: + repo.insert("hello", "desc", "body\n") + changelog = Changelog() + tools = {t.name: t.func for t in create_skill_tools(repo, changelog=changelog)} + + result = tools["delete_skill"](name="hello") + + assert result["applied"] is True + assert "hello" not in repo + assert changelog.changes[0].kind is ChangeKind.DELETE + + +def test_recording_tools_delete_failure(repo: SkillRepo) -> None: + changelog = Changelog() + tools = {t.name: t.func for t in create_skill_tools(repo, changelog=changelog)} + + with pytest.raises(FileNotFoundError): + tools["delete_skill"](name="nope") + + assert len(changelog.failed) == 1 + assert changelog.failed[0].error is not None + assert "nope" in changelog.failed[0].error + + +def test_recording_tools_list_and_read_do_not_record(repo: SkillRepo) -> None: + repo.insert("hello", "desc", "body\n") + changelog = Changelog() + tools = {t.name: t.func for t in create_skill_tools(repo, changelog=changelog)} + + assert tools["list_skills"]() == ["hello"] + result = tools["read_skill"](name="hello") + assert result["name"] == "hello" + assert len(changelog.changes) == 0 + + +# --- ADKCurator.curate (runner mocked) --------------------------------------- + + +def _mock_runner(mock_runner_cls: MagicMock, invoke_fn) -> MagicMock: + """Wire a patched InMemoryRunner so curate() drives ``invoke_fn`` offline.""" + runner = mock_runner_cls.return_value + runner.session_service.create_session = AsyncMock(return_value=SimpleNamespace(id="session-1")) + + async def run_async(*args, **kwargs): + invoke_fn() + return + yield # pragma: no cover - makes this an async generator + + runner.run_async = run_async + return runner + + +@pytest.mark.asyncio +@patch("skillos_adk.curator.InMemoryRunner") +async def test_adk_curator_curate( + mock_runner_cls: MagicMock, + repo: SkillRepo, + mock_model: str, +) -> None: + def fake_invoke() -> None: + repo.insert("from-agent", "Agent created this.", "# From Agent\n") + + _mock_runner(mock_runner_cls, fake_invoke) + + curator = ADKCurator(repo, model=mock_model) + await curator.curate(_sample_history()) + + mock_runner_cls.assert_called_once() + agent = mock_runner_cls.call_args.kwargs["agent"] + assert agent.model == mock_model + assert "from-agent" in repo + + +@pytest.mark.asyncio +@patch("skillos_adk.curator.InMemoryRunner") +async def test_adk_curator_records_tool_calls( + mock_runner_cls: MagicMock, + repo: SkillRepo, + mock_model: str, +) -> None: + def fake_invoke() -> None: + agent = mock_runner_cls.call_args.kwargs["agent"] + tools = {t.name: t.func for t in agent.tools} + tools["insert_skill"](name="new-skill", description="Fresh.", body="# New\n") + try: + tools["delete_skill"](name="nonexistent") + except FileNotFoundError: + pass + + _mock_runner(mock_runner_cls, fake_invoke) + + curator = ADKCurator(repo, model=mock_model) + cl = await curator.curate(_sample_history()) + + assert len(cl.applied) == 1 + assert cl.applied[0].name == "new-skill" + assert len(cl.failed) == 1 + assert cl.failed[0].name == "nonexistent" diff --git a/packages/skillos-adk/tests/test_end_to_end.py b/packages/skillos-adk/tests/test_end_to_end.py new file mode 100644 index 0000000..6d3fced --- /dev/null +++ b/packages/skillos-adk/tests/test_end_to_end.py @@ -0,0 +1,155 @@ +"""End-to-end test: user run -> curator plugin -> skill created in repo. + +The curator's internal ADK runner is mocked so no real LLM is called. The +mocked runner stands in for the curator agent: when "invoked" it calls the +skill tools, mirroring what a real model would do after reasoning about the +conversation. The user-side run is represented by ADK session events fed +through the curator plugin's ``after_run_callback``. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from google.adk.agents.invocation_context import InvocationContext +from google.genai import types +from skillos_adk import ADKCurator +from skillos_core import SkillRepo + + +@pytest.fixture +def repo(tmp_path: Path) -> SkillRepo: + return SkillRepo(str(tmp_path)) + + +def _mock_runner(mock_runner_cls: MagicMock, invoke_fn) -> None: + """Wire a patched InMemoryRunner so curate() drives ``invoke_fn`` offline.""" + runner = mock_runner_cls.return_value + runner.session_service.create_session = AsyncMock(return_value=SimpleNamespace(id="session-1")) + + async def run_async(*args, **kwargs): + invoke_fn() + return + yield # pragma: no cover - makes this an async generator + + runner.run_async = run_async + + +def _event(role: str, parts: list[types.Part]) -> SimpleNamespace: + return SimpleNamespace(content=types.Content(role=role, parts=parts), author=role) + + +def _pdf_session_events() -> list[SimpleNamespace]: + return [ + _event("user", [types.Part(text="Extract the text from invoice.pdf")]), + _event( + "model", + [ + types.Part(text="I'll extract the text from the PDF."), + types.Part( + function_call=types.FunctionCall( + name="run_shell", + args={"command": 'python -c "import pdfplumber; ..."'}, + ) + ), + ], + ), + _event( + "user", + [ + types.Part( + function_response=types.FunctionResponse( + name="run_shell", + response={"output": "Invoice #1234\nTotal: $500.00"}, + ) + ) + ], + ), + _event( + "model", + [types.Part(text="The PDF contains Invoice #1234 with a total of $500.00.")], + ), + ] + + +@pytest.mark.asyncio +@patch("skillos_adk.curator.InMemoryRunner") +async def test_user_run_triggers_curator_plugin_which_creates_skill( + mock_runner_cls: MagicMock, + repo: SkillRepo, +) -> None: + curator = ADKCurator(repo, model="gemini-2.0-flash") + + def fake_invoke() -> None: + agent = mock_runner_cls.call_args.kwargs["agent"] + tools = {t.name: t.func for t in agent.tools} + tools["insert_skill"]( + name="pdf-extraction", + description="Extract text from PDFs using pdfplumber.", + body="# PDF Extraction\n\nUse pdfplumber to extract text from PDF files.\n", + ) + + _mock_runner(mock_runner_cls, fake_invoke) + + # Drive the full path: user session events -> plugin -> curator -> repo. + plugin = curator.plugin() + ctx = cast( + InvocationContext, + SimpleNamespace(session=SimpleNamespace(events=_pdf_session_events())), + ) + await plugin.after_run_callback(invocation_context=ctx) + + # The curator agent saw the conversation and received it as a prompt. + assert mock_runner_cls.call_args.kwargs["agent"].name == "skill_curator" + assert "pdf-extraction" in repo + skill = repo.read("pdf-extraction") + assert "pdfplumber" in skill.body + assert skill.description == "Extract text from PDFs using pdfplumber." + + +@pytest.mark.asyncio +@patch("skillos_adk.curator.InMemoryRunner") +async def test_curator_handles_mixed_success_and_failure( + mock_runner_cls: MagicMock, + repo: SkillRepo, +) -> None: + curator = ADKCurator(repo, model="gemini-2.0-flash") + + def fake_invoke() -> None: + agent = mock_runner_cls.call_args.kwargs["agent"] + tools = {t.name: t.func for t in agent.tools} + tools["insert_skill"](name="good-skill", description="A valid skill.", body="# Good\n") + try: + tools["insert_skill"](name="INVALID NAME", description="bad", body="body\n") + except ValueError: + pass + + _mock_runner(mock_runner_cls, fake_invoke) + + changelog = await curator.curate([{"role": "user", "content": "test"}]) + + assert len(changelog.applied) == 1 + assert len(changelog.failed) == 1 + assert changelog.applied[0].name == "good-skill" + assert "good-skill" in repo + assert "INVALID NAME" not in repo + + +@pytest.mark.asyncio +@patch("skillos_adk.curator.InMemoryRunner") +async def test_curator_no_changes_returns_empty_changelog( + mock_runner_cls: MagicMock, + repo: SkillRepo, +) -> None: + curator = ADKCurator(repo, model="gemini-2.0-flash") + + _mock_runner(mock_runner_cls, lambda: None) + + changelog = await curator.curate([{"role": "user", "content": "nothing interesting"}]) + + assert len(changelog.changes) == 0 + assert repo.list_skills() == [] diff --git a/packages/skillos-adk/tests/test_hook.py b/packages/skillos-adk/tests/test_hook.py new file mode 100644 index 0000000..0270472 --- /dev/null +++ b/packages/skillos-adk/tests/test_hook.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock, patch + +import pytest +from google.adk.agents.invocation_context import InvocationContext +from google.adk.plugins.base_plugin import BasePlugin +from google.genai import types +from skillos_adk import ADKCurator +from skillos_adk.curator import _CuratorPlugin +from skillos_core import SkillRepo + + +@pytest.fixture +def repo(tmp_path: Path) -> SkillRepo: + return SkillRepo(str(tmp_path)) + + +@pytest.fixture +def mock_model() -> str: + return "gemini-2.0-flash" + + +def _event(role: str, parts: list[types.Part]) -> SimpleNamespace: + return SimpleNamespace(content=types.Content(role=role, parts=parts), author=role) + + +def _context(events: list) -> InvocationContext: + # A SimpleNamespace stands in for the InvocationContext: the plugin only + # touches ``.session.events``. + return cast(InvocationContext, SimpleNamespace(session=SimpleNamespace(events=events))) + + +def test_plugin_returns_base_plugin(repo: SkillRepo, mock_model: str) -> None: + curator = ADKCurator(repo, model=mock_model) + plugin = curator.plugin() + assert isinstance(plugin, _CuratorPlugin) + assert isinstance(plugin, BasePlugin) + + +@pytest.mark.asyncio +async def test_plugin_fires_after_run(repo: SkillRepo, mock_model: str) -> None: + curator = ADKCurator(repo, model=mock_model) + + with patch.object(curator, "curate", new_callable=AsyncMock) as mock_curate: + plugin = curator.plugin() + ctx = _context( + [ + _event("user", [types.Part(text="Create a greeting skill.")]), + _event("model", [types.Part(text="Done.")]), + ] + ) + + await plugin.after_run_callback(invocation_context=ctx) + + mock_curate.assert_awaited_once() + assert mock_curate.await_args is not None + history = mock_curate.await_args.args[0] + assert history[0] == {"role": "user", "content": [{"text": "Create a greeting skill."}]} + assert history[1] == {"role": "model", "content": [{"text": "Done."}]} + + +@pytest.mark.asyncio +async def test_plugin_skips_empty_session(repo: SkillRepo, mock_model: str) -> None: + curator = ADKCurator(repo, model=mock_model) + + with patch.object(curator, "curate", new_callable=AsyncMock) as mock_curate: + plugin = curator.plugin() + await plugin.after_run_callback(invocation_context=_context([])) + mock_curate.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_plugin_skips_session_with_no_usable_content( + repo: SkillRepo, mock_model: str +) -> None: + curator = ADKCurator(repo, model=mock_model) + + with patch.object(curator, "curate", new_callable=AsyncMock) as mock_curate: + plugin = curator.plugin() + ctx = _context([SimpleNamespace(content=None, author="model")]) + await plugin.after_run_callback(invocation_context=ctx) + mock_curate.assert_not_awaited() diff --git a/packages/skillos-adk/tests/test_tools.py b/packages/skillos-adk/tests/test_tools.py new file mode 100644 index 0000000..4e991b5 --- /dev/null +++ b/packages/skillos-adk/tests/test_tools.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from skillos_adk import create_skill_tools +from skillos_core import SkillRepo + + +@pytest.fixture +def repo(tmp_path: Path) -> SkillRepo: + return SkillRepo(str(tmp_path)) + + +@pytest.fixture +def tools(repo: SkillRepo): + return {t.name: t.func for t in create_skill_tools(repo)} + + +def test_creates_five_tools(repo: SkillRepo) -> None: + ts = create_skill_tools(repo) + names = {t.name for t in ts} + assert names == {"list_skills", "read_skill", "insert_skill", "update_skill", "delete_skill"} + + +def test_list_skills_empty(tools) -> None: + assert tools["list_skills"]() == [] + + +def test_insert_and_list(tools) -> None: + result = tools["insert_skill"](name="hello", description="A greeting skill.", body="# Hello\n") + assert result["name"] == "hello" + assert result["applied"] is True + assert tools["list_skills"]() == ["hello"] + + +def test_insert_with_optional_fields(tools, repo: SkillRepo) -> None: + tools["insert_skill"]( + name="hello", + description="desc", + body="body\n", + license="Apache-2.0", + allowed_tools=["Read", "Bash"], + compatibility="claude-1.x", + metadata={"version": "0.1.0"}, + ) + skill = repo.read("hello") + assert skill.metadata["license"] == "Apache-2.0" + assert skill.metadata["allowed-tools"] == ["Read", "Bash"] + assert skill.metadata["compatibility"] == "claude-1.x" + assert skill.metadata["metadata"] == {"version": "0.1.0"} + + +def test_insert_defaults_to_mit(tools, repo: SkillRepo) -> None: + tools["insert_skill"](name="hello", description="desc", body="body\n") + assert repo.read("hello").metadata["license"] == "MIT" + + +def test_insert_duplicate_raises(tools) -> None: + tools["insert_skill"](name="hello", description="desc", body="body\n") + with pytest.raises(FileExistsError): + tools["insert_skill"](name="hello", description="desc", body="body\n") + + +def test_insert_invalid_name_raises(tools) -> None: + with pytest.raises(ValueError): + tools["insert_skill"](name="BAD NAME", description="desc", body="body\n") + + +def test_read_skill(tools) -> None: + tools["insert_skill"](name="hello", description="A greeting.", body="# Hello\n") + result = tools["read_skill"](name="hello") + assert result["name"] == "hello" + assert result["description"] == "A greeting." + assert "# Hello" in result["body"] + assert result["resources"] == [] + + +def test_read_missing_raises(tools) -> None: + with pytest.raises(FileNotFoundError): + tools["read_skill"](name="nope") + + +def test_update_description(tools, repo: SkillRepo) -> None: + tools["insert_skill"](name="hello", description="v1", body="body\n") + result = tools["update_skill"](name="hello", description="v2") + assert result["applied"] is True + assert repo.read("hello").description == "v2" + + +def test_update_body(tools, repo: SkillRepo) -> None: + tools["insert_skill"](name="hello", description="desc", body="v1 body\n") + tools["update_skill"](name="hello", body="v2 body\n") + assert "v2 body" in repo.read("hello").body + + +def test_update_leaves_unspecified_fields(tools, repo: SkillRepo) -> None: + tools["insert_skill"]( + name="hello", + description="desc", + body="body\n", + license="Apache-2.0", + ) + tools["update_skill"](name="hello", description="updated") + skill = repo.read("hello") + assert skill.description == "updated" + assert skill.metadata["license"] == "Apache-2.0" + + +def test_update_missing_raises(tools) -> None: + with pytest.raises(FileNotFoundError): + tools["update_skill"](name="nope", description="x") + + +def test_delete_skill(tools, repo: SkillRepo) -> None: + tools["insert_skill"](name="hello", description="desc", body="body\n") + result = tools["delete_skill"](name="hello") + assert result == {"name": "hello", "applied": True, "error": None} + assert "hello" not in repo + + +def test_delete_missing_raises(tools) -> None: + with pytest.raises(FileNotFoundError): + tools["delete_skill"](name="nope") + + +def test_tools_have_descriptions(repo: SkillRepo) -> None: + for t in create_skill_tools(repo): + assert t.description, f"{t.name} missing description" + + +def test_full_lifecycle(tools, repo: SkillRepo) -> None: + tools["insert_skill"](name="alpha", description="first", body="body a\n") + tools["insert_skill"](name="beta", description="second", body="body b\n") + assert tools["list_skills"]() == ["alpha", "beta"] + + tools["update_skill"](name="alpha", description="updated first") + assert repo.read("alpha").description == "updated first" + + tools["delete_skill"](name="beta") + assert tools["list_skills"]() == ["alpha"] + + result = tools["read_skill"](name="alpha") + assert result["description"] == "updated first" diff --git a/pyproject.toml b/pyproject.toml index 45f9c6d..0cd570c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ dev = [ [tool.pytest.ini_options] testpaths = ["packages"] +# importlib mode lets packages share test-module basenames (e.g. test_tools.py) +# without colliding in sys.modules. +addopts = "--import-mode=importlib" [tool.ruff] line-length = 100 diff --git a/uv.lock b/uv.lock index 73d2685..a74679b 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ resolution-markers = [ [manifest] members = [ + "skillos-adk", "skillos-core", "skillos-strands", ] @@ -208,6 +209,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -249,6 +268,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + [[package]] name = "azure-core" version = "1.41.0" @@ -644,6 +676,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "docstring-parser" version = "0.18.0" @@ -665,6 +706,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + [[package]] name = "filelock" version = "3.29.0" @@ -823,6 +880,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/37/3922951a55a3d0f0340e884929087ce08e333cbb16a86002535c095960fc/gcsfs-2026.4.0-py3-none-any.whl", hash = "sha256:d9e838834d8cce6cb623c6a6a5fad66a4d122dc5c609d4b1c1977b55f759dcc5", size = 72190, upload-time = "2026-04-29T21:04:09.997Z" }, ] +[[package]] +name = "google-adk" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosqlite" }, + { name = "authlib" }, + { name = "click" }, + { name = "fastapi" }, + { name = "google-auth", extra = ["pyopenssl"] }, + { name = "google-genai" }, + { name = "graphviz" }, + { name = "httpx" }, + { name = "jsonschema" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "starlette" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "tzlocal" }, + { name = "uvicorn" }, + { name = "watchdog" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/d2/58823ea0d5ac32143773d377b014123191ce420480c003190e1f86a9667c/google_adk-2.1.0.tar.gz", hash = "sha256:fd1709bf5e70e5aaa7d148c7b788d2cd00bb659ee10505a731bb2ad609d28968", size = 3340742, upload-time = "2026-05-23T00:13:56.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/5b/56a9992b6f9447437f29e3691a487b16dfdea42aedec16ceced9de88b9f8/google_adk-2.1.0-py3-none-any.whl", hash = "sha256:4ec8a0ccdf8af90f9fa505c740cae921b47590aee3d61bca14f7bfa8bc886e4e", size = 3852259, upload-time = "2026-05-23T00:13:59.611Z" }, +] + [[package]] name = "google-api-core" version = "2.30.3" @@ -858,6 +950,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/c9/db44165ba7c581268c6d46017ef63339110378305062830104fc7fa144cb/google_auth-2.53.0-py3-none-any.whl", hash = "sha256:6e7449917c599b35126a99ec268ec6880301f2fea41dce198fe8fd83ff642b68", size = 246071, upload-time = "2026-05-15T20:53:05.609Z" }, ] +[package.optional-dependencies] +pyopenssl = [ + { name = "pyopenssl" }, +] +requests = [ + { name = "requests" }, +] + [[package]] name = "google-auth-oauthlib" version = "1.4.0" @@ -953,6 +1053,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, ] +[[package]] +name = "google-genai" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/59/3ed61240ef20b3ae6ed54e82c6f8b6d1f194947bc6679679dd6cdb037594/google_genai-1.75.0.tar.gz", hash = "sha256:56bac3991b311c93f980c0a2abcd287b672146905df1fbd71c92ed633d5a07cf", size = 539039, upload-time = "2026-05-04T22:48:54.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/b6/552d40e96da22921eb1fead7c14b00b5b5473a20e45959488660fab35ee2/google_genai-1.75.0-py3-none-any.whl", hash = "sha256:8dc4c096e7d6288c3087f6893f582fe52468932464781edb8193bd92b9fefb2c", size = 793726, upload-time = "2026-05-04T22:48:53.033Z" }, +] + [[package]] name = "google-resumable-media" version = "2.9.0" @@ -982,6 +1103,15 @@ grpc = [ { name = "grpcio" }, ] +[[package]] +name = "graphviz" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, +] + [[package]] name = "grpc-google-iam-v1" version = "0.14.4" @@ -1135,6 +1265,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -1162,6 +1304,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -1398,19 +1552,20 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.42.1" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.63b1" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1418,50 +1573,50 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/6d/4de72d97ff54db1ed270c7a59c9b904b917c0ac7af429c086c388b824ddb/opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541", size = 41081, upload-time = "2026-05-21T16:36:14.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/a1/9314e621c143e4d82a5bf7a43c2ff7a745d31023506336857607c8c543cc/opentelemetry_instrumentation-0.63b1-py3-none-any.whl", hash = "sha256:f1986716d52cc316ea5f60189098726a9071d8ecc0eee96c9ed110be08bade9c", size = 35577, upload-time = "2026-05-21T16:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" }, ] [[package]] name = "opentelemetry-instrumentation-threading" -version = "0.63b1" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/90/7b0279192fab614d57af3d57584ef7ac9e38fa3df0b1d412224f6f55a85b/opentelemetry_instrumentation_threading-0.63b1.tar.gz", hash = "sha256:afa8c2cada8ed136f07b04dc8739bc861a15e9a5edea1a65e4c5e1919c62946c", size = 9080, upload-time = "2026-05-21T16:36:49.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/2d/2537d5990fa341198cbc8ae70b2c3637037061b8ab1196af1d924a275f55/opentelemetry_instrumentation_threading-0.62b1.tar.gz", hash = "sha256:4b3c876907657e3b8b977bfe15d248f2c02db56302c51883724e7ac2f8ce26d2", size = 9180, upload-time = "2026-04-24T13:23:06.15Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/3d/3a991a4fcdf5ac82c04215e38cea4e73ad63713707014f9a70d1ab257f5f/opentelemetry_instrumentation_threading-0.63b1-py3-none-any.whl", hash = "sha256:33059298e68c94b13c38b562ad28799ec16a2fd06182ebfc762bb4e956e55d94", size = 8486, upload-time = "2026-05-21T16:35:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/aa/37/a80fb13b76f85b4e433ff44b4ba177615823c36c28dca12e94d2c37de681/opentelemetry_instrumentation_threading-0.62b1-py3-none-any.whl", hash = "sha256:4596e79c47de122eb2e85877c1a8bfed1cd6ab06bd2c29d120ebcf8a708a433a", size = 9335, upload-time = "2026-04-24T13:22:19.419Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.42.1" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.63b1" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, ] [[package]] @@ -1863,6 +2018,19 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyopenssl" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -2248,6 +2416,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "skillos-adk" +version = "0.1.0" +source = { editable = "packages/skillos-adk" } +dependencies = [ + { name = "google-adk" }, + { name = "skillos-core" }, +] + +[package.metadata] +requires-dist = [ + { name = "google-adk", specifier = ">=1.0" }, + { name = "skillos-core", editable = "packages/skillos-core" }, +] + [[package]] name = "skillos-core" source = { editable = "packages/skillos-core" } @@ -2300,6 +2483,15 @@ requires-dist = [ { name = "strands-agents", specifier = ">=1.0" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sse-starlette" version = "3.4.4" @@ -2315,15 +2507,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.1.0" +version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] [[package]] @@ -2349,6 +2541,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/4a/7855503e8e270324e1092745712b78e7de50fc64340ed9cf4eea0cd1c57c/strands_agents-1.41.0-py3-none-any.whl", hash = "sha256:1792e488812ce9fd8a9e815cf37b86a5de33f5b9cae5b1e54608129712cea142", size = 438042, upload-time = "2026-05-21T17:50:07.371Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -2449,6 +2650,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -2520,6 +2742,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + [[package]] name = "wrapt" version = "2.2.1" @@ -2721,3 +3002,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]