Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,23 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta

- **CONTRIBUTING**: Category selection guidance for contributors; issue-first policy for new top-level folders (#204).

### Added

- **Framework:** Added `_sanitize_gemini_tool_name()` to `skillware/core/loader.py` to explicitly map provider tool naming constraints.
- **Tests:** Added `test_sanitize_gemini_tool_name()` to `tests/test_loader.py` to verify safe translation and `test_skill_docs_gemini_anti_patterns()` to `tests/test_registry_docs.py` to enforce documentation hygiene by preventing manual tool wrapping and mutation anti-patterns in skill catalog pages.
- **Documentation:** Added canonical dispatch guidelines to `docs/usage/gemini.md` and `docs/usage/skill_usage_template.md`.

### Changed

- **GitHub**: Overhauled issue templates (CLI, Skill Upgrade, Examples, Packaging), issue chooser `config.yml`, PR template, and label taxonomy with pastel colors; labels sync automatically from `.github/labels.json` via CI on merge to `main` (#227).
- **Framework:** `SkillLoader.to_gemini_tool()` now returns a `google.genai.types.Tool` object instead of a raw dictionary, ensuring compatibility with the `google-genai` SDK when passing tools to `GenerateContentConfig`.
- **Tests:** Updated `tests/test_loader.py` to assert against the properties of the `google.genai.types.Tool` object for `to_gemini_tool()`.
- **Documentation:** Updated Gemini integration snippets across all skill catalog pages and `introduction.md` to reflect the `to_gemini_tool()` API change and correct tool name sanitization.
- **Examples:** Removed manual `types.Tool` wrapping, and consistently utilize `SkillLoader._sanitize_gemini_tool_name()` for derived tool names in gemini examples.

### Fixed

- **Framework:** `SkillLoader.to_gemini_tool()` returned a plain dictionary instead of `google.genai.types.Tool`, causing runtime failures when passed to `GenerateContentConfig(tools=...)` with the `google-genai` SDK. Fixes #223.

## [0.4.2] - 2026-07-08

Expand Down
2 changes: 1 addition & 1 deletion docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ When you run `SkillLoader.load_skill("category/skill_name")`, a complex orchestr
### Step 1: Discovery & Loading
The loader resolves `category/skill_name` to a skill directory by checking, in order: an existing path on disk, roots in `SKILLWARE_SKILL_PATH`, a `skills/` folder in the current working directory (or its parents), then bundled skills installed with the package. Each bundle is a directory containing `manifest.yaml` and `skill.py`.
* It dynamically imports the `skill.py` module and auto-discovers the single `BaseSkill` subclass as `bundle["class"]` (no hardcoded class names required).
* It parses the `manifest.yaml` (including `issuer` for attribution, separate from tool-calling fields). Registry skills set `name` to the full ID (`category/skill_name`), which Gemini and Claude use as the tool name; OpenAI and DeepSeek receive a sanitized variant (slashes → underscores). For registry-layout paths (`<skill_root>/<category>/<skill_name>/`), the loader warns when `name` does not match the folder path; flat private layouts (`<skill_root>/<skill_name>/`) skip this check. Loaded bundles expose `registry_id` when validation applies.
* It parses the `manifest.yaml` (including `issuer` for attribution, separate from tool-calling fields). Registry skills set `name` to the full ID (`category/skill_name`), which Claude uses as the tool name; Gemini, OpenAI, and DeepSeek receive a sanitized variant (slashes → underscores). For registry-layout paths (`<skill_root>/<category>/<skill_name>/`), the loader warns when `name` does not match the folder path; flat private layouts (`<skill_root>/<skill_name>/`) skip this check. Loaded bundles expose `registry_id` when validation applies.
* It reads `instructions.md` and, when present, optional `card.json`.

```mermaid
Expand Down
2 changes: 1 addition & 1 deletion docs/skills/evm_tx_handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ response = client.models.generate_content(
system_instruction=bundle["instructions"],
),
)
# On function_call, match bundle["manifest"]["name"] (defi/evm_tx_handler):
# On function_call, match SkillLoader._sanitize_gemini_tool_name(bundle["manifest"]["name"]) (defi_evm_tx_handler):
# skill.execute({"action": ..., "intent": ...})
# After preview + user approval: skill.execute({"action": "execute", "intent": intent, "confirmed": True})
```
Expand Down
4 changes: 1 addition & 3 deletions docs/skills/issue_resolver.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,7 @@ load_env_file()
bundle = SkillLoader.load_skill("dev_tools/issue_resolver")
skill = bundle["class"]()
client = genai.Client()
tool_decl = SkillLoader.to_gemini_tool(bundle)
tool_decl["name"] = SkillLoader._sanitize_function_tool_name("dev_tools/issue_resolver")
gemini_tool = types.Tool(function_declarations=[tool_decl])
gemini_tool = SkillLoader.to_gemini_tool(bundle)
response = client.models.generate_content(
model="gemini-2.5-flash-lite",
contents="Analyze https://github.com/owner/repo/issues/123 and propose a fix plan.",
Expand Down
2 changes: 1 addition & 1 deletion docs/skills/pdf_form_filler.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ bundle = SkillLoader.load_skill("office/pdf_form_filler")
skill = bundle["class"]()
client = genai.Client()
tool = SkillLoader.to_gemini_tool(bundle)
tool_name = bundle["manifest"]["name"]
tool_name = SkillLoader._sanitize_gemini_tool_name(bundle["manifest"]["name"])
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Fill /path/to/form.pdf — name John Doe, check the terms box.",
Expand Down
4 changes: 1 addition & 3 deletions docs/skills/token_limiter.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,7 @@ load_env_file()
bundle = SkillLoader.load_skill("monitoring/token_limiter")
skill = bundle["class"]()
client = genai.Client()
tool_decl = SkillLoader.to_gemini_tool(bundle)
tool_decl["name"] = SkillLoader._sanitize_function_tool_name("monitoring/token_limiter")
gemini_tool = types.Tool(function_declarations=[tool_decl])
gemini_tool = SkillLoader.to_gemini_tool(bundle)
response = client.models.generate_content(
model="gemini-2.5-flash-lite",
contents=(
Expand Down
6 changes: 2 additions & 4 deletions docs/skills/uk_companies_house_handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,8 @@ skill = bundle["class"](
config={"COMPANIES_HOUSE_API_KEY": os.environ.get("COMPANIES_HOUSE_API_KEY")}
)
client = genai.Client()
gemini_decl = SkillLoader.to_gemini_tool(bundle)
gemini_decl["name"] = SkillLoader._sanitize_function_tool_name(gemini_decl["name"])
tool = types.Tool(function_declarations=[gemini_decl])
tool_name = bundle["manifest"]["name"]
tool = SkillLoader.to_gemini_tool(bundle)
tool_name = SkillLoader._sanitize_gemini_tool_name(bundle["manifest"]["name"])
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Who is the CEO of BP?",
Expand Down
2 changes: 1 addition & 1 deletion docs/skills/wallet_screening.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ skill = bundle["class"](
client = genai.Client()
tool = SkillLoader.to_gemini_tool(bundle)
# Use the manifest name so the match stays correct if the name ever changes
tool_name = bundle["manifest"]["name"]
tool_name = SkillLoader._sanitize_gemini_tool_name(bundle["manifest"]["name"])
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Screen wallet 0xd8dA... for sanctions and malicious contract interactions.",
Expand Down
4 changes: 3 additions & 1 deletion docs/usage/gemini.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Gemini uses `FunctionDeclaration` objects (defined in Protobuf) to describe tool
The `manifest.yaml` uses standard JSON Schema types (lowercase `string`, `object`).
Gemini requires Protobuf types (uppercase `STRING`, `OBJECT`).

`SkillLoader.to_gemini_tool()` handles this conversion automatically. It recursively walks your parameter schema and ensures it is compatible with Gemini's backend.
`SkillLoader.to_gemini_tool()` handles this conversion automatically. It recursively walks your parameter schema, sanitizes the tool name, and returns a ready-to-use `types.Tool` object compatible with Gemini's backend.

### 2. Context Injection
Gemini 1.5+ supports `system_instruction`. Skillware leverages this to inject the "Mind" of the skill (`instructions.md`).
Expand All @@ -81,6 +81,8 @@ The `google-genai` SDK returns model parts that can include `function_call` requ
In a manual Skillware loop, execute the matching local skill with `skill.execute(dict(part.function_call.args))`, then send a `function_response` back to Gemini so the model can produce the final answer.
If you use an automatic-calling helper in your own app, keep the same boundary: Skillware executes locally, and the tool result is returned to the model before you show a final response.

**Canonical Dispatch**: When matching the requested `function_call.name` against your skill in a multi-tool agent loop, always match against the sanitized name (e.g. `SkillLoader._sanitize_gemini_tool_name(bundle["manifest"]["name"])` or the adapter-derived `tool.function_declarations[0].name`), not the raw registry ID with slashes.

## 🛠️ Advanced: Manual Execution Loop

If you need granular control (e.g., to sanitize inputs or show progress bars), use the manual loop:
Expand Down
2 changes: 2 additions & 0 deletions docs/usage/skill_usage_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

When adding **Usage Examples** to `docs/skills/<skill>.md`, include subsections for Gemini, Claude, OpenAI, DeepSeek, and Ollama (prompt mode). Each block should be runnable: imports, `load_env_file()`, `load_skill`, `bundle["class"]()` (or explicit `bundle["module"].ClassName()`), adapter, system instructions, sample user message, and `execute` on tool call.

For Gemini sections, ensure the dispatch matches the sanitized tool name (e.g. `SkillLoader._sanitize_gemini_tool_name(bundle["manifest"]["name"])` or adapter-derived name) rather than the raw registry ID with slashes. Do not manually wrap the tool output in `types.Tool(function_declarations=[...])`, as `to_gemini_tool()` already returns a `types.Tool` object.

Link to [agent_loops.md](agent_loops.md) and [README.md](README.md). List skill-specific env vars in an **Environment** table; link to [api_keys.md](api_keys.md) for setup.

Do not duplicate the full API keys guide on skill pages.
4 changes: 3 additions & 1 deletion examples/gemini_evm_tx_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def main() -> None:
client = genai.Client()
tool = SkillLoader.to_gemini_tool(bundle)
system_instruction = bundle["instructions"]
# Derive the tool name from the manifest so this stays correct if the name changes
TOOL_NAME = SkillLoader._sanitize_gemini_tool_name(bundle["manifest"]["name"])

user_query = (
"Plan a buy of 10 DEGEN on Base paying with USDC. "
Expand All @@ -70,7 +72,7 @@ def main() -> None:
fn_args = dict(part.function_call.args)
print(f"Agent tool call: {fn_name} -> {json.dumps(fn_args)}")

if fn_name != bundle["manifest"]["name"]:
if fn_name != TOOL_NAME:
print(f"Unknown tool: {fn_name}")
break

Expand Down
8 changes: 4 additions & 4 deletions examples/gemini_issue_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@
github_token = os.environ.get("GITHUB_TOKEN") or None

client = genai.Client()
tool_decl = SkillLoader.to_gemini_tool(bundle)
tool_decl["name"] = SkillLoader._sanitize_function_tool_name(SKILL_ID)
gemini_tool = types.Tool(function_declarations=[tool_decl])
gemini_fn_name = tool_decl["name"]
gemini_tool = SkillLoader.to_gemini_tool(bundle)
# Derive the tool name from the manifest so this stays correct if the name changes
gemini_fn_name = SkillLoader._sanitize_gemini_tool_name(bundle["manifest"]["name"])

system_instruction = bundle["instructions"]
model = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash-lite")

Expand Down
2 changes: 1 addition & 1 deletion examples/gemini_pdf_form_filler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
client = genai.Client()
tool = SkillLoader.to_gemini_tool(skill_bundle)
system_instruction = skill_bundle["instructions"]
TOOL_NAME = skill_bundle["manifest"]["name"]
TOOL_NAME = SkillLoader._sanitize_gemini_tool_name(skill_bundle["manifest"]["name"])

pdf_path = os.path.abspath("test_form.pdf")
user_query = (
Expand Down
4 changes: 1 addition & 3 deletions examples/gemini_token_limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ def run_gemini_loop(skill, bundle) -> None:

print("\nPhase 2: Gemini tool loop with budget check...")
client = genai.Client()
tool_decl = SkillLoader.to_gemini_tool(bundle)
tool_decl["name"] = SkillLoader._sanitize_function_tool_name(SKILL_ID)
gemini_tool = types.Tool(function_declarations=[tool_decl])
gemini_tool = SkillLoader.to_gemini_tool(bundle)
system_instruction = bundle["instructions"]
model = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash-lite")

Expand Down
4 changes: 3 additions & 1 deletion examples/gemini_tos_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
client = genai.Client()
tool = SkillLoader.to_gemini_tool(bundle)
system_instruction = bundle["instructions"]
# Derive the tool name from the manifest so this stays correct if the name changes
TOOL_NAME = SkillLoader._sanitize_gemini_tool_name(bundle["manifest"]["name"])

user_query = (
"Before scraping Hackernoon tagged AI pages, check whether automated crawling "
Expand All @@ -43,7 +45,7 @@
print(f"Gemini requested tool: {fn_name}")
print(f"Input: {fn_args}")

if fn_name == "compliance/tos_evaluator":
if fn_name == TOOL_NAME:
result = tos_skill.execute(fn_args)
print(json.dumps(result, indent=2))
response = client.models.generate_content(
Expand Down
6 changes: 2 additions & 4 deletions examples/gemini_uk_companies_house_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,7 @@ def main() -> None:
client = genai.Client()

# Convert the manifest to a Gemini function declaration and sanitize the name
gemini_decl = SkillLoader.to_gemini_tool(bundle)
gemini_decl["name"] = SkillLoader._sanitize_function_tool_name(gemini_decl["name"])
tool = types.Tool(function_declarations=[gemini_decl])
tool = SkillLoader.to_gemini_tool(bundle)

system_instruction = bundle["instructions"]

Expand All @@ -69,7 +67,7 @@ def main() -> None:
print(f"Function: {fn_name}")
print(f"Arguments: {json.dumps(fn_args, indent=2)}")

expected_tool_name = SkillLoader._sanitize_function_tool_name(
expected_tool_name = SkillLoader._sanitize_gemini_tool_name(
bundle["manifest"]["name"]
)
if fn_name != expected_tool_name:
Expand Down
2 changes: 1 addition & 1 deletion examples/gemini_wallet_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
tool = SkillLoader.to_gemini_tool(skill_bundle)
system_instruction = skill_bundle["instructions"]
# Derive the tool name from the manifest so this stays correct if the name changes
TOOL_NAME = skill_bundle["manifest"]["name"]
TOOL_NAME = SkillLoader._sanitize_gemini_tool_name(skill_bundle["manifest"]["name"])

user_query = (
"Can you screen this wallet for me? 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
Expand Down
73 changes: 45 additions & 28 deletions skillware/core/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,34 +291,6 @@ def load_skill(skill_path: str) -> Dict[str, Any]:
}
return {}

@staticmethod
def to_gemini_tool(skill_bundle: Dict[str, Any]) -> Dict[str, Any]:
"""
Converts a skill manifest to a Gemini function declaration.
Handles type conversion (lowercase to UPPERCASE) for Gemini Protobuf compatibility.
"""
manifest = skill_bundle.get("manifest", {})
name = manifest.get("name", "unknown_tool")
description = manifest.get("description", "")
parameters = manifest.get("parameters", {})

# Helper to recursively upper-case 'type' fields
def sanitize_schema(schema):
new_schema = schema.copy()
if "type" in new_schema:
new_schema["type"] = new_schema["type"].upper()
if "properties" in new_schema:
new_schema["properties"] = {
k: sanitize_schema(v) for k, v in new_schema["properties"].items()
}
return new_schema

return {
"name": name,
"description": description,
"parameters": sanitize_schema(parameters),
}

@staticmethod
def to_claude_tool(skill_bundle: Dict[str, Any]) -> Dict[str, Any]:
"""
Expand All @@ -345,6 +317,10 @@ def _sanitize_function_tool_name(name: str) -> str:
return "unknown_tool"
return safe[:64]

@staticmethod
def _sanitize_gemini_tool_name(name: str) -> str:
return SkillLoader._sanitize_function_tool_name(name)

@staticmethod
def _sanitize_openai_tool_name(name: str) -> str:
return SkillLoader._sanitize_function_tool_name(name)
Expand All @@ -353,6 +329,47 @@ def _sanitize_openai_tool_name(name: str) -> str:
def _sanitize_deepseek_tool_name(name: str) -> str:
return SkillLoader._sanitize_function_tool_name(name)

@staticmethod
def to_gemini_tool(skill_bundle: Dict[str, Any]) -> Any:
"""
Converts a skill manifest to a Gemini function declaration.
Handles type conversion (lowercase to UPPERCASE) for Gemini Protobuf compatibility.
See: https://ai.google.dev/gemini-api/docs/generate-content/function-calling#how-it-works
"""
try:
from google.genai import types
except ImportError:
raise ImportError(
"google-genai is required for to_gemini_tool. Install with: pip install google-genai"
)

manifest = skill_bundle.get("manifest", {})
raw_name = manifest.get("name", "unknown_tool")
name = SkillLoader._sanitize_gemini_tool_name(raw_name)
description = manifest.get("description", "")
parameters = manifest.get("parameters", {})

# Helper to recursively upper-case 'type' fields
def sanitize_schema(schema):
new_schema = schema.copy()
if "type" in new_schema:
new_schema["type"] = new_schema["type"].upper()
if "properties" in new_schema:
new_schema["properties"] = {
k: sanitize_schema(v) for k, v in new_schema["properties"].items()
}
return new_schema

return types.Tool(
function_declarations=[
{
"name": name,
"description": description,
"parameters": sanitize_schema(parameters),
}
]
)

@staticmethod
def to_openai_tool(skill_bundle: Dict[str, Any]) -> Dict[str, Any]:
"""
Expand Down
23 changes: 19 additions & 4 deletions tests/test_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,18 +316,33 @@ def test_to_ollama_prompt():
def test_to_gemini_tool():
dummy_bundle = {
"manifest": {
"name": "test_gemini_skill",
"name": "finance/wallet_screening",
"parameters": {
"type": "object",
"properties": {"param1": {"type": "string"}},
},
}
}
tool = SkillLoader.to_gemini_tool(dummy_bundle)
assert tool["name"] == "test_gemini_skill"
decl = tool.function_declarations[0]
assert decl.name == "finance_wallet_screening"
assert type(tool).__name__ == "Tool"
# Gemini requires UPPERCASE types for Protobufs
assert tool["parameters"]["type"] == "OBJECT"
assert tool["parameters"]["properties"]["param1"]["type"] == "STRING"
assert decl.parameters.type.name == "OBJECT"
assert decl.parameters.properties["param1"].type.name == "STRING"


def test_sanitize_gemini_tool_name():
assert (
SkillLoader._sanitize_gemini_tool_name("compliance/tos_evaluator")
== "compliance_tos_evaluator"
)
assert (
SkillLoader._sanitize_gemini_tool_name("wallet_screening") == "wallet_screening"
)
assert SkillLoader._sanitize_gemini_tool_name("") == "unknown_tool"
assert SkillLoader._sanitize_gemini_tool_name("a" * 80).startswith("a")
assert len(SkillLoader._sanitize_gemini_tool_name("a" * 80)) == 64


def test_to_claude_tool():
Expand Down
34 changes: 34 additions & 0 deletions tests/test_registry_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,37 @@ def test_agent_loops_reference_all_skills(
assert not missing, "Skills missing from docs/usage/agent_loops.md:\n" + "\n".join(
f" - {skill}" for skill in missing
)


def test_skill_docs_gemini_anti_patterns():
"""Verify Gemini snippets in skill catalog pages do not use anti-patterns."""
docs_root = REPO_ROOT / "docs" / "skills"

anti_patterns = [
(r'tool_decl\["name"\]\s*=', 'tool_decl["name"] mutation'),
(r'gemini_decl\["name"\]\s*=', 'gemini_decl["name"] mutation'),
(
r"types\.Tool\s*\(\s*function_declarations\s*=\s*\[",
"types.Tool(function_declarations=[ manual wrap",
),
(
r'function_call\.name\s*==\s*(?:bundle|skill)\["manifest"\]\["name"\]',
'function_call.name == bundle["manifest"]["name"] without sanitize',
),
]

failures = []

for md_file in docs_root.glob("*.md"):
if md_file.name == "README.md":
continue

content = md_file.read_text(encoding="utf-8")

for pattern, name in anti_patterns:
if re.search(pattern, content):
failures.append(f"{md_file.name}: {name}")

assert not failures, "Gemini anti-patterns found in skill docs:\n" + "\n".join(
f" - {f}" for f in failures
)
Loading