From fef8cac3c7d7d8910705ef3ebbf95a90634b3d1a Mon Sep 17 00:00:00 2001 From: Areen-09 Date: Thu, 9 Jul 2026 07:59:30 +0530 Subject: [PATCH 1/2] fix: to_gemini_tool to return types.Tool object. Fixes #223 --- CHANGELOG.md | 5 ++++ docs/usage/gemini.md | 2 +- examples/gemini_evm_tx_handler.py | 4 ++- examples/gemini_issue_resolver.py | 8 +++--- examples/gemini_pdf_form_filler.py | 2 +- examples/gemini_token_limiter.py | 4 +-- examples/gemini_tos_evaluator.py | 4 ++- examples/gemini_uk_companies_house_handler.py | 4 +-- examples/gemini_wallet_check.py | 2 +- skillware/core/loader.py | 26 ++++++++++++++----- tests/test_loader.py | 7 ++--- 11 files changed, 43 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5763e9f..c679ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta - **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). +### Fixed +- **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`. +- **Examples:** Removed manual `types.Tool` wrapping, and consistently utilize `SkillLoader._sanitize_function_tool_name()` for derived tool names in gemini examples. +- **Tests:** Updated `tests/test_loader.py` to assert against the properties of the `google.genai.types.Tool` object for `to_gemini_tool()`. + ## [0.4.2] - 2026-07-08 ### Changed diff --git a/docs/usage/gemini.md b/docs/usage/gemini.md index 71f43d9..9560c83 100644 --- a/docs/usage/gemini.md +++ b/docs/usage/gemini.md @@ -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`). diff --git a/examples/gemini_evm_tx_handler.py b/examples/gemini_evm_tx_handler.py index 8adcf20..0b22848 100644 --- a/examples/gemini_evm_tx_handler.py +++ b/examples/gemini_evm_tx_handler.py @@ -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_function_tool_name(bundle["manifest"]["name"]) user_query = ( "Plan a buy of 10 DEGEN on Base paying with USDC. " @@ -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 diff --git a/examples/gemini_issue_resolver.py b/examples/gemini_issue_resolver.py index e4935eb..0b7aaf2 100644 --- a/examples/gemini_issue_resolver.py +++ b/examples/gemini_issue_resolver.py @@ -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_function_tool_name(bundle["manifest"]["name"]) + system_instruction = bundle["instructions"] model = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash-lite") diff --git a/examples/gemini_pdf_form_filler.py b/examples/gemini_pdf_form_filler.py index bd9091b..6cce44f 100644 --- a/examples/gemini_pdf_form_filler.py +++ b/examples/gemini_pdf_form_filler.py @@ -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_function_tool_name(skill_bundle["manifest"]["name"]) pdf_path = os.path.abspath("test_form.pdf") user_query = ( diff --git a/examples/gemini_token_limiter.py b/examples/gemini_token_limiter.py index 3747276..b71bbdd 100644 --- a/examples/gemini_token_limiter.py +++ b/examples/gemini_token_limiter.py @@ -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") diff --git a/examples/gemini_tos_evaluator.py b/examples/gemini_tos_evaluator.py index c69bc3f..5661fc3 100644 --- a/examples/gemini_tos_evaluator.py +++ b/examples/gemini_tos_evaluator.py @@ -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_function_tool_name(bundle["manifest"]["name"]) user_query = ( "Before scraping Hackernoon tagged AI pages, check whether automated crawling " @@ -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( diff --git a/examples/gemini_uk_companies_house_handler.py b/examples/gemini_uk_companies_house_handler.py index 68a9d59..bc71b59 100644 --- a/examples/gemini_uk_companies_house_handler.py +++ b/examples/gemini_uk_companies_house_handler.py @@ -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"] diff --git a/examples/gemini_wallet_check.py b/examples/gemini_wallet_check.py index e1fff90..bf4925d 100644 --- a/examples/gemini_wallet_check.py +++ b/examples/gemini_wallet_check.py @@ -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_function_tool_name(skill_bundle["manifest"]["name"]) user_query = ( "Can you screen this wallet for me? 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" diff --git a/skillware/core/loader.py b/skillware/core/loader.py index 049e307..797fb27 100644 --- a/skillware/core/loader.py +++ b/skillware/core/loader.py @@ -292,13 +292,21 @@ def load_skill(skill_path: str) -> Dict[str, Any]: return {} @staticmethod - def to_gemini_tool(skill_bundle: Dict[str, Any]) -> Dict[str, Any]: + 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. """ + 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", {}) - name = manifest.get("name", "unknown_tool") + raw_name = manifest.get("name", "unknown_tool") + name = SkillLoader._sanitize_function_tool_name(raw_name) description = manifest.get("description", "") parameters = manifest.get("parameters", {}) @@ -313,11 +321,15 @@ def sanitize_schema(schema): } return new_schema - return { - "name": name, - "description": description, - "parameters": sanitize_schema(parameters), - } + return types.Tool( + function_declarations=[ + { + "name": name, + "description": description, + "parameters": sanitize_schema(parameters), + } + ] + ) @staticmethod def to_claude_tool(skill_bundle: Dict[str, Any]) -> Dict[str, Any]: diff --git a/tests/test_loader.py b/tests/test_loader.py index d203e86..400d483 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -324,10 +324,11 @@ def test_to_gemini_tool(): } } tool = SkillLoader.to_gemini_tool(dummy_bundle) - assert tool["name"] == "test_gemini_skill" + decl = tool.function_declarations[0] + assert decl.name == "test_gemini_skill" # 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_to_claude_tool(): From c7b38328bab6f18bade21b8c365aa74a556fa190 Mon Sep 17 00:00:00 2001 From: Areen-09 Date: Fri, 10 Jul 2026 09:20:09 +0530 Subject: [PATCH 2/2] fix: address review feedback from #223 and fixes #230 --- CHANGELOG.md | 15 +++- docs/introduction.md | 2 +- docs/skills/evm_tx_handler.md | 2 +- docs/skills/issue_resolver.md | 4 +- docs/skills/pdf_form_filler.md | 2 +- docs/skills/token_limiter.md | 4 +- docs/skills/uk_companies_house_handler.md | 6 +- docs/skills/wallet_screening.md | 2 +- docs/usage/gemini.md | 2 + docs/usage/skill_usage_template.md | 2 + examples/gemini_evm_tx_handler.py | 2 +- examples/gemini_issue_resolver.py | 2 +- examples/gemini_pdf_form_filler.py | 2 +- examples/gemini_tos_evaluator.py | 2 +- examples/gemini_uk_companies_house_handler.py | 2 +- examples/gemini_wallet_check.py | 2 +- skillware/core/loader.py | 75 ++++++++++--------- tests/test_loader.py | 18 ++++- tests/test_registry_docs.py | 34 +++++++++ 19 files changed, 120 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c679ad0..4def4bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +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). - -### Fixed - **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`. -- **Examples:** Removed manual `types.Tool` wrapping, and consistently utilize `SkillLoader._sanitize_function_tool_name()` for derived tool names in gemini examples. - **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 diff --git a/docs/introduction.md b/docs/introduction.md index 57f78f1..0d0fa79 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -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 (`///`), the loader warns when `name` does not match the folder path; flat private layouts (`//`) 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 (`///`), the loader warns when `name` does not match the folder path; flat private layouts (`//`) skip this check. Loaded bundles expose `registry_id` when validation applies. * It reads `instructions.md` and, when present, optional `card.json`. ```mermaid diff --git a/docs/skills/evm_tx_handler.md b/docs/skills/evm_tx_handler.md index aeac295..2e5495b 100644 --- a/docs/skills/evm_tx_handler.md +++ b/docs/skills/evm_tx_handler.md @@ -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}) ``` diff --git a/docs/skills/issue_resolver.md b/docs/skills/issue_resolver.md index 1fc88ef..d9ab9bf 100644 --- a/docs/skills/issue_resolver.md +++ b/docs/skills/issue_resolver.md @@ -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.", diff --git a/docs/skills/pdf_form_filler.md b/docs/skills/pdf_form_filler.md index 0a09d60..c36019a 100644 --- a/docs/skills/pdf_form_filler.md +++ b/docs/skills/pdf_form_filler.md @@ -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.", diff --git a/docs/skills/token_limiter.md b/docs/skills/token_limiter.md index b31b6ac..3f56153 100644 --- a/docs/skills/token_limiter.md +++ b/docs/skills/token_limiter.md @@ -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=( diff --git a/docs/skills/uk_companies_house_handler.md b/docs/skills/uk_companies_house_handler.md index 27acb59..cc17637 100644 --- a/docs/skills/uk_companies_house_handler.md +++ b/docs/skills/uk_companies_house_handler.md @@ -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?", diff --git a/docs/skills/wallet_screening.md b/docs/skills/wallet_screening.md index ee44371..eff2083 100644 --- a/docs/skills/wallet_screening.md +++ b/docs/skills/wallet_screening.md @@ -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.", diff --git a/docs/usage/gemini.md b/docs/usage/gemini.md index 9560c83..68a78ce 100644 --- a/docs/usage/gemini.md +++ b/docs/usage/gemini.md @@ -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: diff --git a/docs/usage/skill_usage_template.md b/docs/usage/skill_usage_template.md index 8f06b41..a626914 100644 --- a/docs/usage/skill_usage_template.md +++ b/docs/usage/skill_usage_template.md @@ -2,6 +2,8 @@ When adding **Usage Examples** to `docs/skills/.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. diff --git a/examples/gemini_evm_tx_handler.py b/examples/gemini_evm_tx_handler.py index 0b22848..982c2c2 100644 --- a/examples/gemini_evm_tx_handler.py +++ b/examples/gemini_evm_tx_handler.py @@ -46,7 +46,7 @@ def main() -> None: 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_function_tool_name(bundle["manifest"]["name"]) + TOOL_NAME = SkillLoader._sanitize_gemini_tool_name(bundle["manifest"]["name"]) user_query = ( "Plan a buy of 10 DEGEN on Base paying with USDC. " diff --git a/examples/gemini_issue_resolver.py b/examples/gemini_issue_resolver.py index 0b7aaf2..4cd5889 100644 --- a/examples/gemini_issue_resolver.py +++ b/examples/gemini_issue_resolver.py @@ -27,7 +27,7 @@ client = genai.Client() 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_function_tool_name(bundle["manifest"]["name"]) +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") diff --git a/examples/gemini_pdf_form_filler.py b/examples/gemini_pdf_form_filler.py index 6cce44f..f2201c1 100644 --- a/examples/gemini_pdf_form_filler.py +++ b/examples/gemini_pdf_form_filler.py @@ -19,7 +19,7 @@ client = genai.Client() tool = SkillLoader.to_gemini_tool(skill_bundle) system_instruction = skill_bundle["instructions"] -TOOL_NAME = SkillLoader._sanitize_function_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 = ( diff --git a/examples/gemini_tos_evaluator.py b/examples/gemini_tos_evaluator.py index 5661fc3..db51143 100644 --- a/examples/gemini_tos_evaluator.py +++ b/examples/gemini_tos_evaluator.py @@ -18,7 +18,7 @@ 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_function_tool_name(bundle["manifest"]["name"]) +TOOL_NAME = SkillLoader._sanitize_gemini_tool_name(bundle["manifest"]["name"]) user_query = ( "Before scraping Hackernoon tagged AI pages, check whether automated crawling " diff --git a/examples/gemini_uk_companies_house_handler.py b/examples/gemini_uk_companies_house_handler.py index bc71b59..eb9bc4e 100644 --- a/examples/gemini_uk_companies_house_handler.py +++ b/examples/gemini_uk_companies_house_handler.py @@ -67,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: diff --git a/examples/gemini_wallet_check.py b/examples/gemini_wallet_check.py index bf4925d..ddf5a34 100644 --- a/examples/gemini_wallet_check.py +++ b/examples/gemini_wallet_check.py @@ -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 = SkillLoader._sanitize_function_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" diff --git a/skillware/core/loader.py b/skillware/core/loader.py index 797fb27..54939de 100644 --- a/skillware/core/loader.py +++ b/skillware/core/loader.py @@ -291,11 +291,50 @@ def load_skill(skill_path: str) -> Dict[str, Any]: } return {} + @staticmethod + def to_claude_tool(skill_bundle: Dict[str, Any]) -> Dict[str, Any]: + """ + Converts a skill manifest to an Anthropic Claude tool definition. + """ + manifest = skill_bundle.get("manifest", {}) + name = manifest.get("name", "unknown_tool") + description = manifest.get("description", "") + parameters = manifest.get("parameters", {}) + + return {"name": name, "description": description, "input_schema": parameters} + + @staticmethod + def _sanitize_function_tool_name(name: str) -> str: + """ + Normalizes manifest tool IDs for OpenAI-compatible function-calling APIs. + Allows letters, digits, underscores, and hyphens (max 64 characters). + """ + if not name or not str(name).strip(): + return "unknown_tool" + safe = re.sub(r"[^a-zA-Z0-9_-]", "_", str(name).replace("/", "_")) + safe = re.sub(r"_+", "_", safe).strip("_") + if not safe: + 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) + + @staticmethod + 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 @@ -306,7 +345,7 @@ def to_gemini_tool(skill_bundle: Dict[str, Any]) -> Any: manifest = skill_bundle.get("manifest", {}) raw_name = manifest.get("name", "unknown_tool") - name = SkillLoader._sanitize_function_tool_name(raw_name) + name = SkillLoader._sanitize_gemini_tool_name(raw_name) description = manifest.get("description", "") parameters = manifest.get("parameters", {}) @@ -331,40 +370,6 @@ def sanitize_schema(schema): ] ) - @staticmethod - def to_claude_tool(skill_bundle: Dict[str, Any]) -> Dict[str, Any]: - """ - Converts a skill manifest to an Anthropic Claude tool definition. - """ - manifest = skill_bundle.get("manifest", {}) - name = manifest.get("name", "unknown_tool") - description = manifest.get("description", "") - parameters = manifest.get("parameters", {}) - - return {"name": name, "description": description, "input_schema": parameters} - - @staticmethod - def _sanitize_function_tool_name(name: str) -> str: - """ - Normalizes manifest tool IDs for OpenAI-compatible function-calling APIs. - Allows letters, digits, underscores, and hyphens (max 64 characters). - """ - if not name or not str(name).strip(): - return "unknown_tool" - safe = re.sub(r"[^a-zA-Z0-9_-]", "_", str(name).replace("/", "_")) - safe = re.sub(r"_+", "_", safe).strip("_") - if not safe: - return "unknown_tool" - return safe[:64] - - @staticmethod - def _sanitize_openai_tool_name(name: str) -> str: - return SkillLoader._sanitize_function_tool_name(name) - - @staticmethod - def _sanitize_deepseek_tool_name(name: str) -> str: - return SkillLoader._sanitize_function_tool_name(name) - @staticmethod def to_openai_tool(skill_bundle: Dict[str, Any]) -> Dict[str, Any]: """ diff --git a/tests/test_loader.py b/tests/test_loader.py index 400d483..d8ee2b2 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -316,7 +316,7 @@ 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"}}, @@ -325,12 +325,26 @@ def test_to_gemini_tool(): } tool = SkillLoader.to_gemini_tool(dummy_bundle) decl = tool.function_declarations[0] - assert decl.name == "test_gemini_skill" + assert decl.name == "finance_wallet_screening" + assert type(tool).__name__ == "Tool" # Gemini requires UPPERCASE types for Protobufs 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(): dummy_bundle = { "manifest": { diff --git a/tests/test_registry_docs.py b/tests/test_registry_docs.py index 5b3a754..692d74d 100644 --- a/tests/test_registry_docs.py +++ b/tests/test_registry_docs.py @@ -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 + )