skillware 0.2.4__tar.gz → 0.2.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {skillware-0.2.4 → skillware-0.2.6}/PKG-INFO +16 -8
- {skillware-0.2.4 → skillware-0.2.6}/README.md +14 -7
- {skillware-0.2.4 → skillware-0.2.6}/pyproject.toml +2 -1
- skillware-0.2.6/skills/compliance/tos_evaluator/__init__.py +3 -0
- skillware-0.2.6/skills/compliance/tos_evaluator/skill.py +587 -0
- skillware-0.2.6/skills/compliance/tos_evaluator/test_skill.py +30 -0
- {skillware-0.2.4 → skillware-0.2.6}/skillware/core/loader.py +64 -0
- {skillware-0.2.4 → skillware-0.2.6}/skillware.egg-info/PKG-INFO +16 -8
- {skillware-0.2.4 → skillware-0.2.6}/skillware.egg-info/SOURCES.txt +5 -1
- {skillware-0.2.4 → skillware-0.2.6}/skillware.egg-info/requires.txt +1 -0
- skillware-0.2.6/tests/test_loader.py +122 -0
- skillware-0.2.6/tests/test_skill_issuer.py +89 -0
- skillware-0.2.4/tests/test_loader.py +0 -63
- {skillware-0.2.4 → skillware-0.2.6}/LICENSE +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/setup.cfg +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/compliance/mica_module/__init__.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/compliance/mica_module/skill.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/compliance/pii_masker/__init__.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/compliance/pii_masker/skill.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/data_engineering/synthetic_generator/__init__.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/data_engineering/synthetic_generator/skill.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/finance/wallet_screening/__init__.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/finance/wallet_screening/maintenance/normalization_tool.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/finance/wallet_screening/maintenance/normalize_uniswap_trm.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/finance/wallet_screening/skill.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/office/pdf_form_filler/skill.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/office/pdf_form_filler/utils.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/optimization/prompt_rewriter/__init__.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skills/optimization/prompt_rewriter/skill.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skillware/__init__.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skillware/core/__init__.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skillware/core/base_skill.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skillware/core/env.py +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skillware.egg-info/dependency_links.txt +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/skillware.egg-info/top_level.txt +0 -0
- {skillware-0.2.4 → skillware-0.2.6}/tests/test_mica_module.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skillware
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: A framework for modular, self-contained AI skills.
|
|
5
5
|
Author-email: ARPA Hellenic Logic Systems <skillware-os@arpacorp.net>
|
|
6
6
|
License: MIT License
|
|
@@ -38,6 +38,7 @@ Requires-Dist: pyyaml
|
|
|
38
38
|
Requires-Dist: anthropic
|
|
39
39
|
Requires-Dist: google-generativeai
|
|
40
40
|
Requires-Dist: pymupdf
|
|
41
|
+
Requires-Dist: beautifulsoup4
|
|
41
42
|
Provides-Extra: dev
|
|
42
43
|
Requires-Dist: pytest; extra == "dev"
|
|
43
44
|
Requires-Dist: pytest-mock; extra == "dev"
|
|
@@ -102,7 +103,8 @@ Skillware/
|
|
|
102
103
|
│ └── skill_name/ # The Skill bundle
|
|
103
104
|
│ ├── manifest.yaml # Definition, schema, and constitution
|
|
104
105
|
│ ├── skill.py # Executable Python logic
|
|
105
|
-
│
|
|
106
|
+
│ ├── instructions.md # Cognitive map for the LLM
|
|
107
|
+
│ └── test_skill.py # Unit tests & schema validation
|
|
106
108
|
├── skillware/ # Core Framework Package
|
|
107
109
|
│ └── core/
|
|
108
110
|
│ ├── base_skill.py # Abstract Base Class for skills
|
|
@@ -172,20 +174,26 @@ print(response.text)
|
|
|
172
174
|
## Documentation
|
|
173
175
|
|
|
174
176
|
* **[Core Logic & Philosophy](docs/introduction.md)**: Details on how Skillware decouples Logic, Cognition, and Governance.
|
|
177
|
+
* **[Testing Guidelines](docs/TESTING.md)**: Instructions for validating skills and checking local coverage.
|
|
178
|
+
* **[Usage guides](docs/usage/README.md)**: Provider adapters and navigation to integration docs.
|
|
179
|
+
* **[Agent loops](docs/usage/agent_loops.md)**: Shared load, tool-call, and `execute` pattern across providers.
|
|
175
180
|
* **[Usage Guide: Gemini](docs/usage/gemini.md)**: Integration with Google's GenAI SDK.
|
|
176
181
|
* **[Usage Guide: Claude](docs/usage/claude.md)**: Integration with Anthropic's SDK.
|
|
182
|
+
* **[Usage Guide: OpenAI](docs/usage/openai.md)**: Integration with OpenAI Chat Completions tool calling.
|
|
183
|
+
* **[Usage Guide: DeepSeek](docs/usage/deepseek.md)**: Integration with the DeepSeek API via `to_deepseek_tool()`.
|
|
177
184
|
* **[Usage Guide: Ollama](docs/usage/ollama.md)**: Native integration for local models via Ollama.
|
|
185
|
+
* **[API Keys for Skills](docs/usage/api_keys.md)**: Environment variables, cloud/CI setup, and security for skills that call external APIs.
|
|
178
186
|
* **[Skill Library](docs/skills/README.md)**: Available capabilities.
|
|
179
187
|
|
|
180
188
|
## Contributing
|
|
181
189
|
|
|
182
|
-
We are building the "App Store" for Agents and require professional, robust, and safe skills.
|
|
190
|
+
We are building the "App Store" for Agents and require professional, robust, and safe skills. We welcome contributions to the skill registry, documentation, tests, and core framework.
|
|
183
191
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
192
|
+
* **[CONTRIBUTING.md](CONTRIBUTING.md)** — Contribution types, skill standard, pull request process, and navigation to all contributor docs.
|
|
193
|
+
* **[Agent Contribution Workflow](docs/contributing/ai_native_workflow.md)** — Workflow for AI agents contributing to the repository (operators supervise).
|
|
194
|
+
* **[Agent Code of Conduct](CODE_OF_CONDUCT.md)** — Deterministic outputs, safety boundaries, and acceptable use of skills.
|
|
195
|
+
* **[TESTING.md](docs/TESTING.md)** — Local linting and pytest before you open a PR.
|
|
196
|
+
* **[Pull request template](.github/PULL_REQUEST_TEMPLATE.md)** — Checklists for skills, docs, and framework changes (complete only the sections that apply).
|
|
189
197
|
|
|
190
198
|
## Comparison
|
|
191
199
|
|
|
@@ -55,7 +55,8 @@ Skillware/
|
|
|
55
55
|
│ └── skill_name/ # The Skill bundle
|
|
56
56
|
│ ├── manifest.yaml # Definition, schema, and constitution
|
|
57
57
|
│ ├── skill.py # Executable Python logic
|
|
58
|
-
│
|
|
58
|
+
│ ├── instructions.md # Cognitive map for the LLM
|
|
59
|
+
│ └── test_skill.py # Unit tests & schema validation
|
|
59
60
|
├── skillware/ # Core Framework Package
|
|
60
61
|
│ └── core/
|
|
61
62
|
│ ├── base_skill.py # Abstract Base Class for skills
|
|
@@ -125,20 +126,26 @@ print(response.text)
|
|
|
125
126
|
## Documentation
|
|
126
127
|
|
|
127
128
|
* **[Core Logic & Philosophy](docs/introduction.md)**: Details on how Skillware decouples Logic, Cognition, and Governance.
|
|
129
|
+
* **[Testing Guidelines](docs/TESTING.md)**: Instructions for validating skills and checking local coverage.
|
|
130
|
+
* **[Usage guides](docs/usage/README.md)**: Provider adapters and navigation to integration docs.
|
|
131
|
+
* **[Agent loops](docs/usage/agent_loops.md)**: Shared load, tool-call, and `execute` pattern across providers.
|
|
128
132
|
* **[Usage Guide: Gemini](docs/usage/gemini.md)**: Integration with Google's GenAI SDK.
|
|
129
133
|
* **[Usage Guide: Claude](docs/usage/claude.md)**: Integration with Anthropic's SDK.
|
|
134
|
+
* **[Usage Guide: OpenAI](docs/usage/openai.md)**: Integration with OpenAI Chat Completions tool calling.
|
|
135
|
+
* **[Usage Guide: DeepSeek](docs/usage/deepseek.md)**: Integration with the DeepSeek API via `to_deepseek_tool()`.
|
|
130
136
|
* **[Usage Guide: Ollama](docs/usage/ollama.md)**: Native integration for local models via Ollama.
|
|
137
|
+
* **[API Keys for Skills](docs/usage/api_keys.md)**: Environment variables, cloud/CI setup, and security for skills that call external APIs.
|
|
131
138
|
* **[Skill Library](docs/skills/README.md)**: Available capabilities.
|
|
132
139
|
|
|
133
140
|
## Contributing
|
|
134
141
|
|
|
135
|
-
We are building the "App Store" for Agents and require professional, robust, and safe skills.
|
|
142
|
+
We are building the "App Store" for Agents and require professional, robust, and safe skills. We welcome contributions to the skill registry, documentation, tests, and core framework.
|
|
136
143
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
144
|
+
* **[CONTRIBUTING.md](CONTRIBUTING.md)** — Contribution types, skill standard, pull request process, and navigation to all contributor docs.
|
|
145
|
+
* **[Agent Contribution Workflow](docs/contributing/ai_native_workflow.md)** — Workflow for AI agents contributing to the repository (operators supervise).
|
|
146
|
+
* **[Agent Code of Conduct](CODE_OF_CONDUCT.md)** — Deterministic outputs, safety boundaries, and acceptable use of skills.
|
|
147
|
+
* **[TESTING.md](docs/TESTING.md)** — Local linting and pytest before you open a PR.
|
|
148
|
+
* **[Pull request template](.github/PULL_REQUEST_TEMPLATE.md)** — Checklists for skills, docs, and framework changes (complete only the sections that apply).
|
|
142
149
|
|
|
143
150
|
## Comparison
|
|
144
151
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "skillware"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.6"
|
|
8
8
|
description = "A framework for modular, self-contained AI skills."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -23,6 +23,7 @@ dependencies = [
|
|
|
23
23
|
"anthropic",
|
|
24
24
|
"google-generativeai",
|
|
25
25
|
"pymupdf",
|
|
26
|
+
"beautifulsoup4",
|
|
26
27
|
]
|
|
27
28
|
requires-python = ">=3.10"
|
|
28
29
|
|
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
from urllib.parse import urljoin, urlparse
|
|
6
|
+
from urllib.robotparser import RobotFileParser
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
import yaml
|
|
10
|
+
from bs4 import BeautifulSoup
|
|
11
|
+
from skillware.core.base_skill import BaseSkill
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import google.generativeai as genai
|
|
15
|
+
except ImportError: # pragma: no cover - dependency is optional at runtime
|
|
16
|
+
genai = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TOSEvaluatorSkill(BaseSkill):
|
|
20
|
+
"""
|
|
21
|
+
Evaluates whether an automated website action appears permissible based on
|
|
22
|
+
robots.txt and discovered legal policy pages.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
WELL_KNOWN_POLICY_PATHS = [
|
|
26
|
+
"/terms",
|
|
27
|
+
"/terms-of-service",
|
|
28
|
+
"/terms-of-use",
|
|
29
|
+
"/tos",
|
|
30
|
+
"/legal/terms",
|
|
31
|
+
"/legal",
|
|
32
|
+
"/conditions",
|
|
33
|
+
"/policies/terms",
|
|
34
|
+
"/acceptable-use",
|
|
35
|
+
"/aup",
|
|
36
|
+
"/developer-terms",
|
|
37
|
+
"/api-terms",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
POLICY_KEYWORDS = {
|
|
41
|
+
"terms": 8,
|
|
42
|
+
"terms of service": 12,
|
|
43
|
+
"terms of use": 12,
|
|
44
|
+
"tos": 6,
|
|
45
|
+
"legal": 5,
|
|
46
|
+
"conditions": 4,
|
|
47
|
+
"user agreement": 8,
|
|
48
|
+
"acceptable use": 10,
|
|
49
|
+
"developer terms": 8,
|
|
50
|
+
"api terms": 10,
|
|
51
|
+
"api policy": 9,
|
|
52
|
+
"robots": 3,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
ACTION_PATTERNS = {
|
|
56
|
+
"scrape": [r"\bscrap", r"\bextract", r"\bharvest", r"\bcollect data\b"],
|
|
57
|
+
"crawl": [r"\bcrawl", r"\bspider", r"\bbot\b", r"\bautomated access\b"],
|
|
58
|
+
"index": [r"\bindex", r"\bsearch engine", r"\barchive", r"\bmirror"],
|
|
59
|
+
"api_use": [r"\bapi\b", r"\bdeveloper\b", r"\bintegration\b"],
|
|
60
|
+
"monitor": [r"\bmonitor", r"\bwatch", r"\btrack", r"\bcheck periodically\b"],
|
|
61
|
+
"download": [r"\bdownload", r"\bexport", r"\bbulk\b"],
|
|
62
|
+
"automated_access": [r"\bautomation\b", r"\bscript", r"\bprogrammatic\b"],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
CLAUSE_PATTERNS = [
|
|
66
|
+
{
|
|
67
|
+
"label": "hard_block",
|
|
68
|
+
"severity": "high",
|
|
69
|
+
"weight": -45,
|
|
70
|
+
"patterns": [
|
|
71
|
+
r"may not scrape",
|
|
72
|
+
r"must not scrape",
|
|
73
|
+
r"no scraping",
|
|
74
|
+
r"no crawlers",
|
|
75
|
+
r"no robots",
|
|
76
|
+
r"no automated means",
|
|
77
|
+
r"prohibited automated access",
|
|
78
|
+
r"automated means.*prohibited",
|
|
79
|
+
r"harvest.*prohibited",
|
|
80
|
+
r"crawl.*prohibited",
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"label": "soft_caution",
|
|
85
|
+
"severity": "medium",
|
|
86
|
+
"weight": -20,
|
|
87
|
+
"patterns": [
|
|
88
|
+
r"prior written consent",
|
|
89
|
+
r"without our permission",
|
|
90
|
+
r"reasonable rate",
|
|
91
|
+
r"rate limit",
|
|
92
|
+
r"commercial use.*restricted",
|
|
93
|
+
r"access.*subject to",
|
|
94
|
+
r"must comply with.*api",
|
|
95
|
+
r"use the api",
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"label": "permission",
|
|
100
|
+
"severity": "low",
|
|
101
|
+
"weight": 18,
|
|
102
|
+
"patterns": [
|
|
103
|
+
r"permitted to access",
|
|
104
|
+
r"you may use.*api",
|
|
105
|
+
r"public api",
|
|
106
|
+
r"developers may access",
|
|
107
|
+
r"search engines may crawl",
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
113
|
+
super().__init__(config)
|
|
114
|
+
self.session = requests.Session()
|
|
115
|
+
self.session.headers.update(
|
|
116
|
+
{
|
|
117
|
+
"User-Agent": "Skillware-TOS-Evaluator/0.1 (+https://github.com/ARPAHLS/skillware)"
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def manifest(self) -> Dict[str, Any]:
|
|
123
|
+
manifest_path = os.path.join(os.path.dirname(__file__), "manifest.yaml")
|
|
124
|
+
if os.path.exists(manifest_path):
|
|
125
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
126
|
+
return yaml.safe_load(f)
|
|
127
|
+
return {}
|
|
128
|
+
|
|
129
|
+
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
130
|
+
normalized = self._normalize_input(params)
|
|
131
|
+
if "error" in normalized:
|
|
132
|
+
return normalized
|
|
133
|
+
|
|
134
|
+
robots_assessment = self._evaluate_robots(
|
|
135
|
+
normalized["origin"], normalized["target_url"], normalized["user_agent"]
|
|
136
|
+
)
|
|
137
|
+
policy_candidates = self._discover_policy_pages(
|
|
138
|
+
normalized["origin"], robots_assessment, normalized["max_terms_pages"]
|
|
139
|
+
)
|
|
140
|
+
tos_assessment = self._evaluate_policy_pages(
|
|
141
|
+
normalized, policy_candidates, normalized["max_terms_pages"]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
llm_assessment = None
|
|
145
|
+
if self._should_use_llm(normalized, robots_assessment, tos_assessment):
|
|
146
|
+
llm_assessment = self._run_llm_evaluator(normalized, tos_assessment)
|
|
147
|
+
|
|
148
|
+
return self._build_final_result(
|
|
149
|
+
normalized,
|
|
150
|
+
robots_assessment,
|
|
151
|
+
tos_assessment,
|
|
152
|
+
llm_assessment,
|
|
153
|
+
policy_candidates,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _normalize_input(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
157
|
+
target_url = params.get("target_url", "").strip()
|
|
158
|
+
intended_action = params.get("intended_action", "").strip()
|
|
159
|
+
if not target_url:
|
|
160
|
+
return {"error": "target_url is required."}
|
|
161
|
+
if not intended_action:
|
|
162
|
+
return {"error": "intended_action is required."}
|
|
163
|
+
|
|
164
|
+
parsed = urlparse(target_url)
|
|
165
|
+
if not parsed.scheme or not parsed.netloc:
|
|
166
|
+
return {"error": "target_url must be a fully qualified URL."}
|
|
167
|
+
|
|
168
|
+
origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
169
|
+
action_type = self._classify_action(intended_action)
|
|
170
|
+
user_agent = params.get("user_agent", self.session.headers["User-Agent"])
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"target_url": target_url,
|
|
174
|
+
"intended_action": intended_action,
|
|
175
|
+
"action_type": action_type,
|
|
176
|
+
"origin": origin,
|
|
177
|
+
"path": parsed.path or "/",
|
|
178
|
+
"user_agent": user_agent,
|
|
179
|
+
"fetch_mode": params.get("fetch_mode", "lightweight"),
|
|
180
|
+
"use_llm_evaluator": bool(params.get("use_llm_evaluator", False)),
|
|
181
|
+
"llm_provider": params.get("llm_provider", "gemini"),
|
|
182
|
+
"llm_model": params.get("llm_model", "gemini-2.5-flash-lite"),
|
|
183
|
+
"assume_authenticated_session": bool(
|
|
184
|
+
params.get("assume_authenticated_session", False)
|
|
185
|
+
),
|
|
186
|
+
"max_terms_pages": max(1, min(int(params.get("max_terms_pages", 5)), 10)),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def _classify_action(self, intended_action: str) -> str:
|
|
190
|
+
lowered = intended_action.lower()
|
|
191
|
+
for action, patterns in self.ACTION_PATTERNS.items():
|
|
192
|
+
if any(re.search(pattern, lowered) for pattern in patterns):
|
|
193
|
+
return action
|
|
194
|
+
return "automated_access"
|
|
195
|
+
|
|
196
|
+
def _evaluate_robots(
|
|
197
|
+
self, origin: str, target_url: str, user_agent: str
|
|
198
|
+
) -> Dict[str, Any]:
|
|
199
|
+
robots_url = f"{origin}/robots.txt"
|
|
200
|
+
assessment = {
|
|
201
|
+
"status": "unavailable",
|
|
202
|
+
"robots_url": robots_url,
|
|
203
|
+
"can_fetch": None,
|
|
204
|
+
"matched_rule": "unknown",
|
|
205
|
+
"crawl_delay": None,
|
|
206
|
+
"request_rate": None,
|
|
207
|
+
"sitemaps": [],
|
|
208
|
+
"reason": "robots.txt could not be retrieved.",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
response = self.session.get(robots_url, timeout=10)
|
|
213
|
+
if response.status_code >= 400:
|
|
214
|
+
assessment["reason"] = f"robots.txt returned HTTP {response.status_code}."
|
|
215
|
+
return assessment
|
|
216
|
+
|
|
217
|
+
parser = RobotFileParser()
|
|
218
|
+
parser.set_url(robots_url)
|
|
219
|
+
parser.parse(response.text.splitlines())
|
|
220
|
+
|
|
221
|
+
can_fetch = parser.can_fetch(user_agent, target_url)
|
|
222
|
+
assessment["status"] = "parsed"
|
|
223
|
+
assessment["can_fetch"] = can_fetch
|
|
224
|
+
assessment["matched_rule"] = "allowed" if can_fetch else "disallowed"
|
|
225
|
+
assessment["crawl_delay"] = parser.crawl_delay(user_agent)
|
|
226
|
+
assessment["request_rate"] = parser.request_rate(user_agent)
|
|
227
|
+
assessment["sitemaps"] = parser.site_maps() or []
|
|
228
|
+
assessment["reason"] = (
|
|
229
|
+
"robots.txt allows the target path."
|
|
230
|
+
if can_fetch
|
|
231
|
+
else "robots.txt disallows the target path for the supplied user-agent."
|
|
232
|
+
)
|
|
233
|
+
return assessment
|
|
234
|
+
except requests.RequestException as exc:
|
|
235
|
+
assessment["reason"] = f"robots.txt request failed: {str(exc)}"
|
|
236
|
+
return assessment
|
|
237
|
+
|
|
238
|
+
def _discover_policy_pages(
|
|
239
|
+
self, origin: str, robots_assessment: Dict[str, Any], max_terms_pages: int
|
|
240
|
+
) -> Dict[str, List[Dict[str, Any]]]:
|
|
241
|
+
candidates: Dict[str, Dict[str, Any]] = {}
|
|
242
|
+
|
|
243
|
+
for path in self.WELL_KNOWN_POLICY_PATHS:
|
|
244
|
+
url = urljoin(origin, path)
|
|
245
|
+
score = 50 if "terms" in path or "acceptable" in path else 35
|
|
246
|
+
candidates[url] = {
|
|
247
|
+
"url": url,
|
|
248
|
+
"score": score,
|
|
249
|
+
"source": "well_known_path",
|
|
250
|
+
"label": path.strip("/") or "legal",
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
html_pages = [origin]
|
|
254
|
+
for sitemap_url in robots_assessment.get("sitemaps", [])[:2]:
|
|
255
|
+
html_pages.append(sitemap_url)
|
|
256
|
+
|
|
257
|
+
for url in html_pages:
|
|
258
|
+
discovered = self._extract_candidate_links(url, origin)
|
|
259
|
+
for item in discovered:
|
|
260
|
+
existing = candidates.get(item["url"])
|
|
261
|
+
if existing:
|
|
262
|
+
existing["score"] = max(existing["score"], item["score"])
|
|
263
|
+
existing["source"] = f"{existing['source']},{item['source']}"
|
|
264
|
+
else:
|
|
265
|
+
candidates[item["url"]] = item
|
|
266
|
+
|
|
267
|
+
ordered = sorted(candidates.values(), key=lambda item: item["score"], reverse=True)
|
|
268
|
+
return {"candidates": ordered[:max_terms_pages]}
|
|
269
|
+
|
|
270
|
+
def _extract_candidate_links(self, page_url: str, origin: str) -> List[Dict[str, Any]]:
|
|
271
|
+
links: List[Dict[str, Any]] = []
|
|
272
|
+
response = self._safe_get(page_url, timeout=10)
|
|
273
|
+
if not response or "html" not in response.headers.get("Content-Type", "").lower():
|
|
274
|
+
return links
|
|
275
|
+
|
|
276
|
+
soup = BeautifulSoup(response.text[:300000], "html.parser")
|
|
277
|
+
for anchor in soup.find_all("a", href=True):
|
|
278
|
+
href = anchor.get("href", "").strip()
|
|
279
|
+
text = " ".join(anchor.stripped_strings).strip().lower()
|
|
280
|
+
if not href:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
absolute = urljoin(page_url, href)
|
|
284
|
+
parsed = urlparse(absolute)
|
|
285
|
+
if not parsed.scheme.startswith("http"):
|
|
286
|
+
continue
|
|
287
|
+
if f"{parsed.scheme}://{parsed.netloc}" != origin:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
score = self._score_policy_link(absolute.lower(), text)
|
|
291
|
+
if score <= 0:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
links.append(
|
|
295
|
+
{
|
|
296
|
+
"url": absolute,
|
|
297
|
+
"score": score,
|
|
298
|
+
"source": "link_discovery",
|
|
299
|
+
"label": text or parsed.path,
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
return links
|
|
304
|
+
|
|
305
|
+
def _score_policy_link(self, href: str, text: str) -> int:
|
|
306
|
+
combined = f"{href} {text}".lower()
|
|
307
|
+
score = 0
|
|
308
|
+
for keyword, weight in self.POLICY_KEYWORDS.items():
|
|
309
|
+
if keyword in combined:
|
|
310
|
+
score += weight
|
|
311
|
+
return score
|
|
312
|
+
|
|
313
|
+
def _evaluate_policy_pages(
|
|
314
|
+
self,
|
|
315
|
+
normalized: Dict[str, Any],
|
|
316
|
+
policy_candidates: Dict[str, List[Dict[str, Any]]],
|
|
317
|
+
max_terms_pages: int,
|
|
318
|
+
) -> Dict[str, Any]:
|
|
319
|
+
pages_evaluated = []
|
|
320
|
+
clause_hits = []
|
|
321
|
+
|
|
322
|
+
for candidate in policy_candidates.get("candidates", [])[:max_terms_pages]:
|
|
323
|
+
url = candidate["url"]
|
|
324
|
+
response = self._safe_get(url, timeout=12)
|
|
325
|
+
if not response:
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
content_type = response.headers.get("Content-Type", "").lower()
|
|
329
|
+
if "html" not in content_type:
|
|
330
|
+
pages_evaluated.append(
|
|
331
|
+
{
|
|
332
|
+
"url": url,
|
|
333
|
+
"status": "skipped",
|
|
334
|
+
"reason": f"Unsupported content type: {content_type or 'unknown'}",
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
continue
|
|
338
|
+
|
|
339
|
+
extracted_sections = self._extract_policy_sections(response.text)
|
|
340
|
+
page_hits = self._score_policy_sections(
|
|
341
|
+
normalized["action_type"], extracted_sections, url
|
|
342
|
+
)
|
|
343
|
+
clause_hits.extend(page_hits)
|
|
344
|
+
pages_evaluated.append(
|
|
345
|
+
{
|
|
346
|
+
"url": url,
|
|
347
|
+
"status": "parsed",
|
|
348
|
+
"candidate_score": candidate["score"],
|
|
349
|
+
"matched_clauses": len(page_hits),
|
|
350
|
+
}
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
clause_hits.sort(key=lambda item: abs(item["score_delta"]), reverse=True)
|
|
354
|
+
aggregate_score = sum(item["score_delta"] for item in clause_hits)
|
|
355
|
+
if not pages_evaluated:
|
|
356
|
+
status = "insufficient_evidence"
|
|
357
|
+
summary = "No candidate Terms or policy pages could be parsed."
|
|
358
|
+
elif any(item["classification"] == "hard_block" for item in clause_hits):
|
|
359
|
+
status = "blocked"
|
|
360
|
+
summary = "Discovered policy text contains an explicit restriction on the requested automated behavior."
|
|
361
|
+
elif aggregate_score <= -20:
|
|
362
|
+
status = "caution"
|
|
363
|
+
summary = "Discovered policy text suggests restrictions or conditions on the requested automated behavior."
|
|
364
|
+
elif aggregate_score > 0:
|
|
365
|
+
status = "allowed"
|
|
366
|
+
summary = "Discovered policy text includes language that appears permissive for the requested behavior."
|
|
367
|
+
else:
|
|
368
|
+
status = "insufficient_evidence"
|
|
369
|
+
summary = "Policy pages were found, but none produced strong action-specific evidence."
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
"status": status,
|
|
373
|
+
"summary": summary,
|
|
374
|
+
"pages_evaluated": pages_evaluated,
|
|
375
|
+
"matched_clauses": clause_hits[:10],
|
|
376
|
+
"aggregate_score": aggregate_score,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
def _extract_policy_sections(self, html: str) -> List[Dict[str, str]]:
|
|
380
|
+
soup = BeautifulSoup(html[:400000], "html.parser")
|
|
381
|
+
for tag in soup(["script", "style", "noscript", "svg"]):
|
|
382
|
+
tag.decompose()
|
|
383
|
+
|
|
384
|
+
body = soup.body or soup
|
|
385
|
+
sections: List[Dict[str, str]] = []
|
|
386
|
+
current_heading = "General"
|
|
387
|
+
|
|
388
|
+
for element in body.find_all(["h1", "h2", "h3", "p", "li"]):
|
|
389
|
+
text = " ".join(element.stripped_strings)
|
|
390
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
391
|
+
if not text or len(text) < 20:
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
if element.name in {"h1", "h2", "h3"}:
|
|
395
|
+
current_heading = text[:160]
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
sections.append({"heading": current_heading, "text": text[:1200]})
|
|
399
|
+
|
|
400
|
+
return sections[:200]
|
|
401
|
+
|
|
402
|
+
def _score_policy_sections(
|
|
403
|
+
self, action_type: str, sections: List[Dict[str, str]], page_url: str
|
|
404
|
+
) -> List[Dict[str, Any]]:
|
|
405
|
+
hits = []
|
|
406
|
+
action_relevance_patterns = self.ACTION_PATTERNS.get(action_type, [])
|
|
407
|
+
for section in sections:
|
|
408
|
+
text_lower = section["text"].lower()
|
|
409
|
+
heading_lower = section["heading"].lower()
|
|
410
|
+
combined = f"{heading_lower} {text_lower}"
|
|
411
|
+
|
|
412
|
+
if action_relevance_patterns and not any(
|
|
413
|
+
re.search(pattern, combined) for pattern in action_relevance_patterns
|
|
414
|
+
):
|
|
415
|
+
generic_automation = re.search(
|
|
416
|
+
r"automated|bot|crawl|scrap|harvest|api|programmatic", combined
|
|
417
|
+
)
|
|
418
|
+
if not generic_automation:
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
for clause in self.CLAUSE_PATTERNS:
|
|
422
|
+
for pattern in clause["patterns"]:
|
|
423
|
+
if re.search(pattern, combined):
|
|
424
|
+
hits.append(
|
|
425
|
+
{
|
|
426
|
+
"url": page_url,
|
|
427
|
+
"heading": section["heading"],
|
|
428
|
+
"snippet": section["text"],
|
|
429
|
+
"classification": clause["label"],
|
|
430
|
+
"severity": clause["severity"],
|
|
431
|
+
"score_delta": clause["weight"],
|
|
432
|
+
}
|
|
433
|
+
)
|
|
434
|
+
break
|
|
435
|
+
|
|
436
|
+
return hits
|
|
437
|
+
|
|
438
|
+
def _should_use_llm(
|
|
439
|
+
self,
|
|
440
|
+
normalized: Dict[str, Any],
|
|
441
|
+
robots_assessment: Dict[str, Any],
|
|
442
|
+
tos_assessment: Dict[str, Any],
|
|
443
|
+
) -> bool:
|
|
444
|
+
if not normalized.get("use_llm_evaluator"):
|
|
445
|
+
return False
|
|
446
|
+
if tos_assessment["status"] in {"blocked", "allowed"}:
|
|
447
|
+
return False
|
|
448
|
+
if robots_assessment.get("can_fetch") is False:
|
|
449
|
+
return False
|
|
450
|
+
return bool(tos_assessment.get("matched_clauses") or tos_assessment["status"] == "caution")
|
|
451
|
+
|
|
452
|
+
def _run_llm_evaluator(
|
|
453
|
+
self, normalized: Dict[str, Any], tos_assessment: Dict[str, Any]
|
|
454
|
+
) -> Dict[str, Any]:
|
|
455
|
+
provider = normalized["llm_provider"].lower()
|
|
456
|
+
if provider != "gemini":
|
|
457
|
+
return {
|
|
458
|
+
"status": "skipped",
|
|
459
|
+
"reason": f"Unsupported llm_provider '{normalized['llm_provider']}'.",
|
|
460
|
+
}
|
|
461
|
+
if genai is None:
|
|
462
|
+
return {"status": "skipped", "reason": "google-generativeai is not installed."}
|
|
463
|
+
|
|
464
|
+
api_key = os.environ.get("GOOGLE_API_KEY")
|
|
465
|
+
if not api_key:
|
|
466
|
+
return {"status": "skipped", "reason": "GOOGLE_API_KEY is not configured."}
|
|
467
|
+
|
|
468
|
+
genai.configure(api_key=api_key)
|
|
469
|
+
prompt = {
|
|
470
|
+
"target_url": normalized["target_url"],
|
|
471
|
+
"intended_action": normalized["intended_action"],
|
|
472
|
+
"action_type": normalized["action_type"],
|
|
473
|
+
"matched_clauses": tos_assessment.get("matched_clauses", [])[:6],
|
|
474
|
+
"task": (
|
|
475
|
+
"Classify whether these policy snippets forbid, allow, or condition "
|
|
476
|
+
"the requested action. Return strict JSON with keys: "
|
|
477
|
+
"verdict, confidence_score, rationale."
|
|
478
|
+
),
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
model = genai.GenerativeModel(normalized["llm_model"])
|
|
483
|
+
response = model.generate_content(
|
|
484
|
+
json.dumps(prompt, ensure_ascii=True),
|
|
485
|
+
generation_config=genai.GenerationConfig(
|
|
486
|
+
response_mime_type="application/json", temperature=0.0
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
parsed = json.loads(response.text)
|
|
490
|
+
return {
|
|
491
|
+
"status": "used",
|
|
492
|
+
"provider": provider,
|
|
493
|
+
"model": normalized["llm_model"],
|
|
494
|
+
"verdict": parsed.get("verdict", "CAUTION"),
|
|
495
|
+
"confidence_score": float(parsed.get("confidence_score", 0.5)),
|
|
496
|
+
"rationale": parsed.get("rationale", "No rationale returned."),
|
|
497
|
+
}
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
return {"status": "error", "reason": f"LLM evaluator failed: {str(exc)}"}
|
|
500
|
+
|
|
501
|
+
def _build_final_result(
|
|
502
|
+
self,
|
|
503
|
+
normalized: Dict[str, Any],
|
|
504
|
+
robots_assessment: Dict[str, Any],
|
|
505
|
+
tos_assessment: Dict[str, Any],
|
|
506
|
+
llm_assessment: Optional[Dict[str, Any]],
|
|
507
|
+
policy_candidates: Dict[str, List[Dict[str, Any]]],
|
|
508
|
+
) -> Dict[str, Any]:
|
|
509
|
+
verdict = "INSUFFICIENT_EVIDENCE"
|
|
510
|
+
confidence_score = 0.35
|
|
511
|
+
reason = "Insufficient policy evidence to safely approve the requested action."
|
|
512
|
+
recommended_next_step = "Review the discovered policy pages manually before proceeding."
|
|
513
|
+
|
|
514
|
+
if robots_assessment.get("can_fetch") is False:
|
|
515
|
+
verdict = "UNSAFE"
|
|
516
|
+
confidence_score = 0.98
|
|
517
|
+
reason = robots_assessment["reason"]
|
|
518
|
+
recommended_next_step = (
|
|
519
|
+
"Do not automate access to this path unless you have explicit permission."
|
|
520
|
+
)
|
|
521
|
+
elif tos_assessment["status"] == "blocked":
|
|
522
|
+
verdict = "UNSAFE"
|
|
523
|
+
confidence_score = 0.9
|
|
524
|
+
reason = tos_assessment["summary"]
|
|
525
|
+
recommended_next_step = "Avoid the requested action or obtain explicit written permission."
|
|
526
|
+
elif tos_assessment["status"] == "caution":
|
|
527
|
+
verdict = "CAUTION"
|
|
528
|
+
confidence_score = 0.65
|
|
529
|
+
reason = tos_assessment["summary"]
|
|
530
|
+
recommended_next_step = "Prefer an official API or documented integration path if one exists."
|
|
531
|
+
elif tos_assessment["status"] == "allowed" and robots_assessment.get("can_fetch") is not False:
|
|
532
|
+
verdict = "SAFE"
|
|
533
|
+
confidence_score = 0.72
|
|
534
|
+
reason = tos_assessment["summary"]
|
|
535
|
+
recommended_next_step = "Proceed conservatively and continue honoring crawl delays and rate limits."
|
|
536
|
+
|
|
537
|
+
if llm_assessment and llm_assessment.get("status") == "used":
|
|
538
|
+
verdict = llm_assessment.get("verdict", verdict)
|
|
539
|
+
confidence_score = max(
|
|
540
|
+
confidence_score,
|
|
541
|
+
llm_assessment.get("confidence_score", confidence_score),
|
|
542
|
+
)
|
|
543
|
+
reason = llm_assessment.get("rationale", reason)
|
|
544
|
+
|
|
545
|
+
evidence = []
|
|
546
|
+
if robots_assessment.get("reason"):
|
|
547
|
+
evidence.append(
|
|
548
|
+
{
|
|
549
|
+
"source": robots_assessment.get("robots_url"),
|
|
550
|
+
"type": "robots",
|
|
551
|
+
"snippet": robots_assessment["reason"],
|
|
552
|
+
}
|
|
553
|
+
)
|
|
554
|
+
for clause in tos_assessment.get("matched_clauses", [])[:5]:
|
|
555
|
+
evidence.append(
|
|
556
|
+
{
|
|
557
|
+
"source": clause["url"],
|
|
558
|
+
"type": clause["classification"],
|
|
559
|
+
"heading": clause["heading"],
|
|
560
|
+
"snippet": clause["snippet"],
|
|
561
|
+
}
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
"is_safe_to_proceed": verdict == "SAFE",
|
|
566
|
+
"confidence_score": round(float(confidence_score), 2),
|
|
567
|
+
"verdict": verdict,
|
|
568
|
+
"reason": reason,
|
|
569
|
+
"recommended_next_step": recommended_next_step,
|
|
570
|
+
"action_type": normalized["action_type"],
|
|
571
|
+
"robots_assessment": robots_assessment,
|
|
572
|
+
"tos_assessment": tos_assessment,
|
|
573
|
+
"llm_assessment": llm_assessment or {"status": "not_used"},
|
|
574
|
+
"discovered_policy_urls": {
|
|
575
|
+
"candidates": [item["url"] for item in policy_candidates.get("candidates", [])]
|
|
576
|
+
},
|
|
577
|
+
"evidence": evidence,
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
def _safe_get(self, url: str, timeout: int = 10) -> Optional[requests.Response]:
|
|
581
|
+
try:
|
|
582
|
+
response = self.session.get(url, timeout=timeout, allow_redirects=True)
|
|
583
|
+
if response.status_code >= 400:
|
|
584
|
+
return None
|
|
585
|
+
return response
|
|
586
|
+
except requests.RequestException:
|
|
587
|
+
return None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from .skill import TOSEvaluatorSkill
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def skill():
|
|
11
|
+
return TOSEvaluatorSkill()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def manifest():
|
|
16
|
+
manifest_path = os.path.join(os.path.dirname(__file__), "manifest.yaml")
|
|
17
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
18
|
+
return yaml.safe_load(f)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_skill_manifest_consistency(skill, manifest):
|
|
22
|
+
skill_manifest = skill.manifest
|
|
23
|
+
assert skill_manifest["name"] == manifest["name"]
|
|
24
|
+
assert skill_manifest["version"] == manifest["version"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_skill_execution_requires_inputs(skill):
|
|
28
|
+
result = skill.execute({})
|
|
29
|
+
assert "error" in result
|
|
30
|
+
assert "target_url" in result["error"]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import re
|
|
2
3
|
import yaml
|
|
3
4
|
import json
|
|
4
5
|
import importlib.util
|
|
@@ -126,6 +127,69 @@ class SkillLoader:
|
|
|
126
127
|
|
|
127
128
|
return {"name": name, "description": description, "input_schema": parameters}
|
|
128
129
|
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _sanitize_function_tool_name(name: str) -> str:
|
|
132
|
+
"""
|
|
133
|
+
Normalizes manifest tool IDs for OpenAI-compatible function-calling APIs.
|
|
134
|
+
Allows letters, digits, underscores, and hyphens (max 64 characters).
|
|
135
|
+
"""
|
|
136
|
+
if not name or not str(name).strip():
|
|
137
|
+
return "unknown_tool"
|
|
138
|
+
safe = re.sub(r"[^a-zA-Z0-9_-]", "_", str(name).replace("/", "_"))
|
|
139
|
+
safe = re.sub(r"_+", "_", safe).strip("_")
|
|
140
|
+
if not safe:
|
|
141
|
+
return "unknown_tool"
|
|
142
|
+
return safe[:64]
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _sanitize_openai_tool_name(name: str) -> str:
|
|
146
|
+
return SkillLoader._sanitize_function_tool_name(name)
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _sanitize_deepseek_tool_name(name: str) -> str:
|
|
150
|
+
return SkillLoader._sanitize_function_tool_name(name)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def to_openai_tool(skill_bundle: Dict[str, Any]) -> Dict[str, Any]:
|
|
154
|
+
"""
|
|
155
|
+
Converts a skill manifest to an OpenAI Chat Completions tool definition.
|
|
156
|
+
See: https://platform.openai.com/docs/guides/function-calling
|
|
157
|
+
"""
|
|
158
|
+
manifest = skill_bundle.get("manifest", {})
|
|
159
|
+
raw_name = manifest.get("name", "unknown_tool")
|
|
160
|
+
description = manifest.get("description", "")
|
|
161
|
+
parameters = manifest.get("parameters", {})
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
"type": "function",
|
|
165
|
+
"function": {
|
|
166
|
+
"name": SkillLoader._sanitize_openai_tool_name(raw_name),
|
|
167
|
+
"description": description,
|
|
168
|
+
"parameters": parameters,
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def to_deepseek_tool(skill_bundle: Dict[str, Any]) -> Dict[str, Any]:
|
|
174
|
+
"""
|
|
175
|
+
Converts a skill manifest to a DeepSeek API tool definition.
|
|
176
|
+
DeepSeek uses an OpenAI-compatible tools schema; this adapter is separate from
|
|
177
|
+
to_openai_tool() by design. See: https://api-docs.deepseek.com/
|
|
178
|
+
"""
|
|
179
|
+
manifest = skill_bundle.get("manifest", {})
|
|
180
|
+
raw_name = manifest.get("name", "unknown_tool")
|
|
181
|
+
description = manifest.get("description", "")
|
|
182
|
+
parameters = manifest.get("parameters", {})
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
"type": "function",
|
|
186
|
+
"function": {
|
|
187
|
+
"name": SkillLoader._sanitize_deepseek_tool_name(raw_name),
|
|
188
|
+
"description": description,
|
|
189
|
+
"parameters": parameters,
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
|
|
129
193
|
@staticmethod
|
|
130
194
|
def to_ollama_prompt(skill_bundle: Dict[str, Any]) -> str:
|
|
131
195
|
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skillware
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: A framework for modular, self-contained AI skills.
|
|
5
5
|
Author-email: ARPA Hellenic Logic Systems <skillware-os@arpacorp.net>
|
|
6
6
|
License: MIT License
|
|
@@ -38,6 +38,7 @@ Requires-Dist: pyyaml
|
|
|
38
38
|
Requires-Dist: anthropic
|
|
39
39
|
Requires-Dist: google-generativeai
|
|
40
40
|
Requires-Dist: pymupdf
|
|
41
|
+
Requires-Dist: beautifulsoup4
|
|
41
42
|
Provides-Extra: dev
|
|
42
43
|
Requires-Dist: pytest; extra == "dev"
|
|
43
44
|
Requires-Dist: pytest-mock; extra == "dev"
|
|
@@ -102,7 +103,8 @@ Skillware/
|
|
|
102
103
|
│ └── skill_name/ # The Skill bundle
|
|
103
104
|
│ ├── manifest.yaml # Definition, schema, and constitution
|
|
104
105
|
│ ├── skill.py # Executable Python logic
|
|
105
|
-
│
|
|
106
|
+
│ ├── instructions.md # Cognitive map for the LLM
|
|
107
|
+
│ └── test_skill.py # Unit tests & schema validation
|
|
106
108
|
├── skillware/ # Core Framework Package
|
|
107
109
|
│ └── core/
|
|
108
110
|
│ ├── base_skill.py # Abstract Base Class for skills
|
|
@@ -172,20 +174,26 @@ print(response.text)
|
|
|
172
174
|
## Documentation
|
|
173
175
|
|
|
174
176
|
* **[Core Logic & Philosophy](docs/introduction.md)**: Details on how Skillware decouples Logic, Cognition, and Governance.
|
|
177
|
+
* **[Testing Guidelines](docs/TESTING.md)**: Instructions for validating skills and checking local coverage.
|
|
178
|
+
* **[Usage guides](docs/usage/README.md)**: Provider adapters and navigation to integration docs.
|
|
179
|
+
* **[Agent loops](docs/usage/agent_loops.md)**: Shared load, tool-call, and `execute` pattern across providers.
|
|
175
180
|
* **[Usage Guide: Gemini](docs/usage/gemini.md)**: Integration with Google's GenAI SDK.
|
|
176
181
|
* **[Usage Guide: Claude](docs/usage/claude.md)**: Integration with Anthropic's SDK.
|
|
182
|
+
* **[Usage Guide: OpenAI](docs/usage/openai.md)**: Integration with OpenAI Chat Completions tool calling.
|
|
183
|
+
* **[Usage Guide: DeepSeek](docs/usage/deepseek.md)**: Integration with the DeepSeek API via `to_deepseek_tool()`.
|
|
177
184
|
* **[Usage Guide: Ollama](docs/usage/ollama.md)**: Native integration for local models via Ollama.
|
|
185
|
+
* **[API Keys for Skills](docs/usage/api_keys.md)**: Environment variables, cloud/CI setup, and security for skills that call external APIs.
|
|
178
186
|
* **[Skill Library](docs/skills/README.md)**: Available capabilities.
|
|
179
187
|
|
|
180
188
|
## Contributing
|
|
181
189
|
|
|
182
|
-
We are building the "App Store" for Agents and require professional, robust, and safe skills.
|
|
190
|
+
We are building the "App Store" for Agents and require professional, robust, and safe skills. We welcome contributions to the skill registry, documentation, tests, and core framework.
|
|
183
191
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
192
|
+
* **[CONTRIBUTING.md](CONTRIBUTING.md)** — Contribution types, skill standard, pull request process, and navigation to all contributor docs.
|
|
193
|
+
* **[Agent Contribution Workflow](docs/contributing/ai_native_workflow.md)** — Workflow for AI agents contributing to the repository (operators supervise).
|
|
194
|
+
* **[Agent Code of Conduct](CODE_OF_CONDUCT.md)** — Deterministic outputs, safety boundaries, and acceptable use of skills.
|
|
195
|
+
* **[TESTING.md](docs/TESTING.md)** — Local linting and pytest before you open a PR.
|
|
196
|
+
* **[Pull request template](.github/PULL_REQUEST_TEMPLATE.md)** — Checklists for skills, docs, and framework changes (complete only the sections that apply).
|
|
189
197
|
|
|
190
198
|
## Comparison
|
|
191
199
|
|
|
@@ -5,6 +5,9 @@ skills/compliance/mica_module/__init__.py
|
|
|
5
5
|
skills/compliance/mica_module/skill.py
|
|
6
6
|
skills/compliance/pii_masker/__init__.py
|
|
7
7
|
skills/compliance/pii_masker/skill.py
|
|
8
|
+
skills/compliance/tos_evaluator/__init__.py
|
|
9
|
+
skills/compliance/tos_evaluator/skill.py
|
|
10
|
+
skills/compliance/tos_evaluator/test_skill.py
|
|
8
11
|
skills/data_engineering/synthetic_generator/__init__.py
|
|
9
12
|
skills/data_engineering/synthetic_generator/skill.py
|
|
10
13
|
skills/finance/wallet_screening/__init__.py
|
|
@@ -26,4 +29,5 @@ skillware/core/base_skill.py
|
|
|
26
29
|
skillware/core/env.py
|
|
27
30
|
skillware/core/loader.py
|
|
28
31
|
tests/test_loader.py
|
|
29
|
-
tests/test_mica_module.py
|
|
32
|
+
tests/test_mica_module.py
|
|
33
|
+
tests/test_skill_issuer.py
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from skillware.core.loader import SkillLoader
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_load_skill_not_found():
|
|
6
|
+
with pytest.raises(FileNotFoundError):
|
|
7
|
+
SkillLoader.load_skill("nonexistent_skill_path_12345")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_to_ollama_prompt():
|
|
11
|
+
dummy_bundle = {
|
|
12
|
+
"manifest": {
|
|
13
|
+
"name": "test_ollama_skill",
|
|
14
|
+
"description": "A very useful test skill.",
|
|
15
|
+
"parameters": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"arg1": {"type": "string", "description": "The first arg"}
|
|
19
|
+
},
|
|
20
|
+
"required": ["arg1"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
prompt = SkillLoader.to_ollama_prompt(dummy_bundle)
|
|
26
|
+
assert "### Tool: `test_ollama_skill`" in prompt
|
|
27
|
+
assert "**Description:** A very useful test skill." in prompt
|
|
28
|
+
assert "- `arg1` (string): The first arg [Required]" in prompt
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_to_gemini_tool():
|
|
32
|
+
dummy_bundle = {
|
|
33
|
+
"manifest": {
|
|
34
|
+
"name": "test_gemini_skill",
|
|
35
|
+
"parameters": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"param1": {"type": "string"}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
tool = SkillLoader.to_gemini_tool(dummy_bundle)
|
|
44
|
+
assert tool["name"] == "test_gemini_skill"
|
|
45
|
+
# Gemini requires UPPERCASE types for Protobufs
|
|
46
|
+
assert tool["parameters"]["type"] == "OBJECT"
|
|
47
|
+
assert tool["parameters"]["properties"]["param1"]["type"] == "STRING"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_to_claude_tool():
|
|
51
|
+
dummy_bundle = {
|
|
52
|
+
"manifest": {
|
|
53
|
+
"name": "test_claude_skill",
|
|
54
|
+
"description": "desc",
|
|
55
|
+
"parameters": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {"arg_claude": {"type": "string"}}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
tool = SkillLoader.to_claude_tool(dummy_bundle)
|
|
62
|
+
assert tool["name"] == "test_claude_skill"
|
|
63
|
+
assert tool["input_schema"]["type"] == "object"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_sanitize_openai_tool_name():
|
|
67
|
+
assert (
|
|
68
|
+
SkillLoader._sanitize_openai_tool_name("compliance/tos_evaluator")
|
|
69
|
+
== "compliance_tos_evaluator"
|
|
70
|
+
)
|
|
71
|
+
assert SkillLoader._sanitize_openai_tool_name("wallet_screening") == "wallet_screening"
|
|
72
|
+
assert SkillLoader._sanitize_openai_tool_name("") == "unknown_tool"
|
|
73
|
+
assert SkillLoader._sanitize_openai_tool_name("a" * 80).startswith("a")
|
|
74
|
+
assert len(SkillLoader._sanitize_openai_tool_name("a" * 80)) == 64
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_to_openai_tool():
|
|
78
|
+
dummy_bundle = {
|
|
79
|
+
"manifest": {
|
|
80
|
+
"name": "compliance/tos_evaluator",
|
|
81
|
+
"description": "Evaluate site policy.",
|
|
82
|
+
"parameters": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"properties": {
|
|
85
|
+
"target_url": {"type": "string", "description": "URL"}
|
|
86
|
+
},
|
|
87
|
+
"required": ["target_url"],
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
tool = SkillLoader.to_openai_tool(dummy_bundle)
|
|
92
|
+
assert tool["type"] == "function"
|
|
93
|
+
assert tool["function"]["name"] == "compliance_tos_evaluator"
|
|
94
|
+
assert tool["function"]["description"] == "Evaluate site policy."
|
|
95
|
+
assert tool["function"]["parameters"]["type"] == "object"
|
|
96
|
+
assert "target_url" in tool["function"]["parameters"]["properties"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_sanitize_deepseek_tool_name():
|
|
100
|
+
assert (
|
|
101
|
+
SkillLoader._sanitize_deepseek_tool_name("compliance/tos_evaluator")
|
|
102
|
+
== "compliance_tos_evaluator"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_to_deepseek_tool():
|
|
107
|
+
dummy_bundle = {
|
|
108
|
+
"manifest": {
|
|
109
|
+
"name": "compliance/tos_evaluator",
|
|
110
|
+
"description": "Evaluate site policy.",
|
|
111
|
+
"parameters": {
|
|
112
|
+
"type": "object",
|
|
113
|
+
"properties": {
|
|
114
|
+
"target_url": {"type": "string", "description": "URL"}
|
|
115
|
+
},
|
|
116
|
+
"required": ["target_url"],
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
tool = SkillLoader.to_deepseek_tool(dummy_bundle)
|
|
121
|
+
assert tool["type"] == "function"
|
|
122
|
+
assert tool["function"]["name"] == "compliance_tos_evaluator"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Registry skills must declare issuer attribution (name + email required)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
9
|
+
SKILLS_ROOT = REPO_ROOT / "skills"
|
|
10
|
+
|
|
11
|
+
PLACEHOLDER_NAMES = frozenset({"your name"})
|
|
12
|
+
PLACEHOLDER_EMAILS = frozenset({"you@example.com"})
|
|
13
|
+
PLACEHOLDER_GITHUB = frozenset({"your_github_username"})
|
|
14
|
+
PLACEHOLDER_ORGS = frozenset({"your org"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _discover_skill_dirs():
|
|
18
|
+
if not SKILLS_ROOT.is_dir():
|
|
19
|
+
return []
|
|
20
|
+
return sorted(p.parent for p in SKILLS_ROOT.rglob("manifest.yaml"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_manifest(skill_dir: Path) -> dict:
|
|
24
|
+
with open(skill_dir / "manifest.yaml", encoding="utf-8") as f:
|
|
25
|
+
data = yaml.safe_load(f)
|
|
26
|
+
return data if isinstance(data, dict) else {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _assert_real_issuer(issuer: dict, context: str) -> None:
|
|
30
|
+
assert isinstance(issuer, dict), f"{context}: issuer must be a mapping"
|
|
31
|
+
|
|
32
|
+
name = (issuer.get("name") or "").strip()
|
|
33
|
+
email = (issuer.get("email") or "").strip()
|
|
34
|
+
assert name, f"{context}: issuer.name is required"
|
|
35
|
+
assert email, f"{context}: issuer.email is required"
|
|
36
|
+
|
|
37
|
+
assert name.lower() not in PLACEHOLDER_NAMES, (
|
|
38
|
+
f"{context}: issuer.name must not be a template placeholder"
|
|
39
|
+
)
|
|
40
|
+
assert email.lower() not in PLACEHOLDER_EMAILS, (
|
|
41
|
+
f"{context}: issuer.email must not be a template placeholder"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
github = (issuer.get("github") or "").strip()
|
|
45
|
+
if github:
|
|
46
|
+
assert github.lower() not in PLACEHOLDER_GITHUB, (
|
|
47
|
+
f"{context}: issuer.github must not be a template placeholder"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
org = (issuer.get("org") or "").strip()
|
|
51
|
+
if org:
|
|
52
|
+
assert org.lower() not in PLACEHOLDER_ORGS, (
|
|
53
|
+
f"{context}: issuer.org must not be a template placeholder"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_registry_skills_declare_issuer():
|
|
58
|
+
skill_dirs = _discover_skill_dirs()
|
|
59
|
+
assert skill_dirs, "expected at least one skill under skills/"
|
|
60
|
+
|
|
61
|
+
for skill_dir in skill_dirs:
|
|
62
|
+
rel = skill_dir.relative_to(REPO_ROOT).as_posix()
|
|
63
|
+
manifest = _load_manifest(skill_dir)
|
|
64
|
+
_assert_real_issuer(manifest.get("issuer"), f"{rel} manifest.yaml")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_registry_card_issuer_matches_manifest_when_present():
|
|
68
|
+
for skill_dir in _discover_skill_dirs():
|
|
69
|
+
rel = skill_dir.relative_to(REPO_ROOT).as_posix()
|
|
70
|
+
manifest_issuer = _load_manifest(skill_dir).get("issuer")
|
|
71
|
+
_assert_real_issuer(manifest_issuer, f"{rel} manifest.yaml")
|
|
72
|
+
|
|
73
|
+
card_path = skill_dir / "card.json"
|
|
74
|
+
if not card_path.is_file():
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
with open(card_path, encoding="utf-8") as f:
|
|
78
|
+
card = json.load(f)
|
|
79
|
+
|
|
80
|
+
card_issuer = card.get("issuer")
|
|
81
|
+
assert card_issuer is not None, f"{rel} card.json should include issuer"
|
|
82
|
+
_assert_real_issuer(card_issuer, f"{rel} card.json")
|
|
83
|
+
|
|
84
|
+
assert (card_issuer.get("name") or "").strip() == (
|
|
85
|
+
manifest_issuer.get("name") or ""
|
|
86
|
+
).strip(), f"{rel}: card issuer.name should match manifest"
|
|
87
|
+
assert (card_issuer.get("email") or "").strip() == (
|
|
88
|
+
manifest_issuer.get("email") or ""
|
|
89
|
+
).strip(), f"{rel}: card issuer.email should match manifest"
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from skillware.core.loader import SkillLoader
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def test_load_skill_not_found():
|
|
6
|
-
with pytest.raises(FileNotFoundError):
|
|
7
|
-
SkillLoader.load_skill("nonexistent_skill_path_12345")
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_to_ollama_prompt():
|
|
11
|
-
dummy_bundle = {
|
|
12
|
-
"manifest": {
|
|
13
|
-
"name": "test_ollama_skill",
|
|
14
|
-
"description": "A very useful test skill.",
|
|
15
|
-
"parameters": {
|
|
16
|
-
"type": "object",
|
|
17
|
-
"properties": {
|
|
18
|
-
"arg1": {"type": "string", "description": "The first arg"}
|
|
19
|
-
},
|
|
20
|
-
"required": ["arg1"]
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
prompt = SkillLoader.to_ollama_prompt(dummy_bundle)
|
|
26
|
-
assert "### Tool: `test_ollama_skill`" in prompt
|
|
27
|
-
assert "**Description:** A very useful test skill." in prompt
|
|
28
|
-
assert "- `arg1` (string): The first arg [Required]" in prompt
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_to_gemini_tool():
|
|
32
|
-
dummy_bundle = {
|
|
33
|
-
"manifest": {
|
|
34
|
-
"name": "test_gemini_skill",
|
|
35
|
-
"parameters": {
|
|
36
|
-
"type": "object",
|
|
37
|
-
"properties": {
|
|
38
|
-
"param1": {"type": "string"}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
tool = SkillLoader.to_gemini_tool(dummy_bundle)
|
|
44
|
-
assert tool["name"] == "test_gemini_skill"
|
|
45
|
-
# Gemini requires UPPERCASE types for Protobufs
|
|
46
|
-
assert tool["parameters"]["type"] == "OBJECT"
|
|
47
|
-
assert tool["parameters"]["properties"]["param1"]["type"] == "STRING"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_to_claude_tool():
|
|
51
|
-
dummy_bundle = {
|
|
52
|
-
"manifest": {
|
|
53
|
-
"name": "test_claude_skill",
|
|
54
|
-
"description": "desc",
|
|
55
|
-
"parameters": {
|
|
56
|
-
"type": "object",
|
|
57
|
-
"properties": {"arg_claude": {"type": "string"}}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
tool = SkillLoader.to_claude_tool(dummy_bundle)
|
|
62
|
-
assert tool["name"] == "test_claude_skill"
|
|
63
|
-
assert tool["input_schema"]["type"] == "object"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|