diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index df211dbec5..312cc5f7b9 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -69,7 +69,7 @@ from open_webui.utils.chat_id import is_saved_chat_id from open_webui.utils.json_codec import JSONCodec from open_webui.utils.notifications import notify_target from open_webui.utils.sanitize import sanitize_code -from open_webui.utils.skill_files import SkillFile, SkillFileOperation +from open_webui.utils.skill_files import SkillFile, SkillFileOperation, bounded_skill_manifest, skill_content_page log = logging.getLogger(__name__) @@ -3488,7 +3488,7 @@ async def view_skill( __metadata__: dict = None, __event_call__: callable = None, ) -> str: - """Load the current SKILL.md and its file manifest. Supporting file reads use this same snapshot. + """Read skill instructions and file list. Use read_skill_file to continue from next_offset. :param id: Skill ID from the available skills manifest. """ @@ -3528,8 +3528,8 @@ async def view_skill( 'id': skill.id, 'version_id': version_id, 'name': snapshot['name'], - 'content': snapshot['content'], - 'files': file_summaries(snapshot['data']['files']), + **skill_content_page(snapshot['content']), + **bounded_skill_manifest(file_summaries(snapshot['data']['files'])), }, ensure_ascii=False, ) @@ -3545,8 +3545,9 @@ async def read_skill_file( __request__: Request = None, __user__: dict = None, __metadata__: dict = None, + __event_call__: callable = None, ) -> str: - """Read one skill resource from the snapshot loaded by view_skill, or the current snapshot if none was loaded. + """Read a skill file from the loaded snapshot. Terminal skills support SKILL.md only. :param id: Skill ID. :param path: Relative path within the skill. @@ -3562,6 +3563,26 @@ async def read_skill_file( if not __user__ or __request__ is None: raise ValueError('Request and user context required') + if id.startswith('terminal:'): + from open_webui.utils.terminals import get_terminal_skill + + if path != 'SKILL.md': + raise ValueError('Terminal skills support SKILL.md only; use terminal tools for supporting files.') + skill = await get_terminal_skill( + __request__, + __user__, + __metadata__ if __metadata__ is not None else {}, + unquote(id.removeprefix('terminal:')), + {'__event_call__': __event_call__}, + offset=offset, + max_chars=max_chars, + refresh=False, + ) + if not skill: + raise ValueError(f"Skill '{id}' not found") + return JSONCodec.dumps( + {'path': path, 'content': skill['content'], 'next_offset': skill['next_offset']}, ensure_ascii=False + ) skill = await authorized_skill(id, SimpleNamespace(**__user__)) if not skill.is_active: await authorized_skill(id, SimpleNamespace(**__user__), 'write') @@ -3583,13 +3604,11 @@ async def read_skill_file( 'url': '/workspace/skills/edit?' + urlencode({'id': id, 'version_id': version_id, 'path': path}), } ) - offset, max_chars = max(0, offset), min(100000, max(1, max_chars)) return JSONCodec.dumps( { 'path': path, 'version_id': version_id, - 'content': file['content'][offset : offset + max_chars], - 'next_offset': offset + max_chars if offset + max_chars < len(file['content']) else None, + **skill_content_page(file['content'], offset, max_chars), }, ensure_ascii=False, ) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 2d69ca267a..6b39e46763 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2782,7 +2782,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Otherwise, save any tools that filter inlets added for merging later. inlet_filter_tools = None if payload_tools is not None else form_data.get('tools', None) - # Mentioned skills get full content; selected/default skills can be loaded through view_skill. + # Mentioned skills get bounded content; selected/default skills can be loaded through view_skill. chat_context = metadata.get('chat_context') or {} metadata['chat_context'] = chat_context skill_versions = chat_context.setdefault('skill_versions', {}) @@ -2824,6 +2824,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): if skill_ids or (use_builtin_tools and model_builtin_tools.get('skills', True)): from open_webui.models.skills import Skills as SkillsModel + from open_webui.utils.skill_files import bounded_skill_manifest, format_skill_content, skill_content_page from open_webui.utils.terminals import ( format_terminal_skill_context, format_terminal_skill_manifest_entry, @@ -2859,8 +2860,14 @@ async def process_chat_payload(request, form_data, user, metadata, model): raise continue skill_versions[skill.id] = version_id - root_content = snapshot['content'] - resources = '\n'.join(f['path'] for f in snapshot['data']['files'] if f['path'] != 'SKILL.md') + skill_tools_enabled = use_builtin_tools and model_builtin_tools.get('skills', True) + root_content = format_skill_content( + skill_content_page(snapshot['content']), skill.id, skill_tools_enabled + ) + manifest = bounded_skill_manifest( + [f['path'] for f in snapshot['data']['files'] if f['path'] != 'SKILL.md'] + ) + resources = '\n'.join(manifest['files']) resource_hint = ( f'\nRead supporting files with read_skill_file(id="{skill.id}", path=...).\n{resources}' if use_builtin_tools and model_builtin_tools.get('skills', True) @@ -2868,8 +2875,11 @@ async def process_chat_payload(request, form_data, user, metadata, model): if resources else '' ) + if manifest.get('notice'): + resource_hint += '\n' + manifest['notice'] form_data['messages'] = add_or_update_system_message( - f'\n{root_content}{resource_hint}\n', + f'\n' + f'{root_content}{resource_hint}\n', form_data['messages'], append=True, ) @@ -2901,7 +2911,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): loaded = await get_terminal_skill(request, user.model_dump(), metadata, skill_name, extra_params) if loaded: form_data['messages'] = add_or_update_system_message( - format_terminal_skill_context(loaded), + format_terminal_skill_context( + loaded, sid, use_builtin_tools and model_builtin_tools.get('skills', True) + ), form_data['messages'], append=True, ) diff --git a/backend/open_webui/utils/skill_files.py b/backend/open_webui/utils/skill_files.py index 9622d7ee61..f03c1c1766 100644 --- a/backend/open_webui/utils/skill_files.py +++ b/backend/open_webui/utils/skill_files.py @@ -16,6 +16,41 @@ MAX_FILE_BYTES = 10 * 1024 * 1024 MAX_SKILL_BYTES = 50 * 1024 * 1024 MAX_IMPORT_BYTES = 200 * 1024 * 1024 MAX_FILES = 1000 +SKILL_CONTENT_MAX_CHARS = 100_000 +SKILL_MANIFEST_MAX_ENTRIES = 50 +SKILL_MANIFEST_MAX_CHARS = 5_000 + + +def skill_content_page(content: str, offset: int = 0, max_chars: int = SKILL_CONTENT_MAX_CHARS) -> dict: + offset = max(0, offset) + end = offset + min(SKILL_CONTENT_MAX_CHARS, max(1, max_chars)) + return {'content': content[offset:end], 'next_offset': end if end < len(content) else None} + + +def bounded_skill_manifest(entries: list, field: str = 'files') -> dict: + bounded, size = [], 2 # Include the JSON array brackets and separators in the budget. + for entry in entries[:SKILL_MANIFEST_MAX_ENTRIES]: + entry_size = len(json.dumps(entry, ensure_ascii=False)) + (2 if bounded else 0) + if size + entry_size > SKILL_MANIFEST_MAX_CHARS: + break + bounded.append(entry) + size += entry_size + result = {field: bounded} + if len(bounded) < len(entries): + result['notice'] = 'Additional supporting files omitted.' + return result + + +def format_skill_content(page: dict, skill_id: str, tools_enabled: bool) -> str: + content = page['content'] + if page['next_offset'] is not None: + content += '\nSkill instructions truncated.' + if tools_enabled: + content += ( + f'\nContinue reading with read_skill_file(id={json.dumps(skill_id)}, ' + f'path="SKILL.md", offset={page["next_offset"]}).' + ) + return content class SkillFile(BaseModel): diff --git a/backend/open_webui/utils/terminals.py b/backend/open_webui/utils/terminals.py index c764ca2efb..771f058c75 100644 --- a/backend/open_webui/utils/terminals.py +++ b/backend/open_webui/utils/terminals.py @@ -1,6 +1,7 @@ """Shared routing helpers for admin-configured terminal servers.""" import asyncio +import hashlib import logging import ntpath import posixpath @@ -8,6 +9,12 @@ from urllib.parse import quote from open_webui.env import ENABLE_TOOL_SERVERS from open_webui.utils.chat_id import is_saved_chat_id +from open_webui.utils.skill_files import ( + SKILL_CONTENT_MAX_CHARS, + bounded_skill_manifest, + format_skill_content, + skill_content_page, +) TERMINAL_CONTEXT_HEADER = 'X-Terminal-Context-Id' TERMINAL_CONTEXT_DEFAULT = 'default' @@ -226,36 +233,56 @@ def add_terminal_agents_md(messages: list[dict], agents_md: str) -> list[dict]: async def get_terminal_skill( - request, user, metadata: dict, skill_name: str, extra_params: dict | None = None + request, + user, + metadata: dict, + skill_name: str, + extra_params: dict | None = None, + *, + offset: int = 0, + max_chars: int = SKILL_CONTENT_MAX_CHARS, + refresh: bool = True, ) -> dict | None: skill = await get_terminal_json( request, user, metadata, f'/skills/read?name={quote(skill_name, safe="")}', extra_params ) - if not isinstance(skill, dict): + if not isinstance(skill, dict) or not isinstance(skill.get('content'), str): return None + context = metadata.get('chat_context') or {} + metadata['chat_context'] = context + fingerprints = context.setdefault('terminal_skill_fingerprints', {}).setdefault(metadata['terminal_id'], {}) + skill_id = f'terminal:{quote(skill_name, safe="")}' + fingerprint = hashlib.sha256(skill['content'].encode('utf-8')).hexdigest() + previous = fingerprints.get(skill_id) + if not refresh and previous is not None and previous != fingerprint: + raise ValueError('Skill changed while reading. Call view_skill again and restart reading.') + fingerprints[skill_id] = fingerprint + location = skill.get('location') or skill.get('path') or '' directory = location.rsplit('/', 1)[0] if '/' in location else location resources = skill.get('resources') if isinstance(skill.get('resources'), list) else [] return { 'name': skill.get('name'), 'description': skill.get('description'), - 'content': skill.get('content'), + **skill_content_page(skill['content'], offset, max_chars), 'directory': directory, - 'resources': resources, + **bounded_skill_manifest(resources, 'resources'), } -def format_terminal_skill_context(skill: dict) -> str: +def format_terminal_skill_context(skill: dict, skill_id: str, tools_enabled: bool) -> str: resources = skill.get('resources') if isinstance(skill.get('resources'), list) else [] - parts = [f'', skill.get('content') or ''] + parts = [f'', format_skill_content(skill, skill_id, tools_enabled)] if skill.get('directory'): parts.append(f'{skill["directory"]}') if resources: parts.append('') parts.extend(f'{resource}' for resource in resources) parts.append('') + if skill.get('notice'): + parts.append(skill['notice']) parts.append('') return '\n'.join(parts) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index f80ff77d2b..90e91f9f23 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -806,7 +806,7 @@ async def get_builtin_tools( ] ) - # Skills tools - view_skill allows model to load full skill instructions on demand + # Skills tools - view_skill loads bounded instructions, with read_skill_file for continuation. if is_builtin_tool_enabled('skills'): builtin_functions.extend([view_skill, read_skill_file, update_skill_files]) if user.get('role') == 'admin' or await has_permission(