diff --git a/CHANGELOG.md b/CHANGELOG.md index 5763e9f..4def4bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 71f43d9..68a78ce 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`). @@ -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 8adcf20..982c2c2 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_gemini_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..4cd5889 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_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 bd9091b..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 = 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_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..db51143 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_gemini_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..eb9bc4e 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"] @@ -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: diff --git a/examples/gemini_wallet_check.py b/examples/gemini_wallet_check.py index e1fff90..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 = 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 049e307..54939de 100644 --- a/skillware/core/loader.py +++ b/skillware/core/loader.py @@ -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]: """ @@ -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) @@ -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]: """ diff --git a/tests/test_loader.py b/tests/test_loader.py index d203e86..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"}}, @@ -324,10 +324,25 @@ 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 == "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(): 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 + )