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(