This commit is contained in:
Timothy Jaeryang Baek 2026-10-13 18:31:54 +04:00
parent fc1005f09b
commit 200b951491
4 changed files with 108 additions and 22 deletions

View file

@ -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, bounded_skill_manifest, skill_content_page
from open_webui.utils.skill_files import SkillFile, SkillFileOperation, skill_content_page, skill_overview
log = logging.getLogger(__name__)
@ -3506,7 +3506,9 @@ async def view_skill(
__metadata__: dict = None,
__event_call__: callable = None,
) -> str:
"""Read skill instructions and file list. Use read_skill_file to continue from next_offset.
"""Read skill instructions and file list. Large skills return an opening preview and section offsets.
Use read_skill_file to read applicable sections, including mandatory rules, when the instructions are incomplete.
:param id: Skill ID from the available skills manifest.
"""
@ -3519,7 +3521,12 @@ async def view_skill(
skill_name = unquote(id.removeprefix(terminal_skill_prefix))
skill = await get_terminal_skill(
__request__, __user__, __metadata__ or {}, skill_name, {'__event_call__': __event_call__}
__request__,
__user__,
__metadata__ if __metadata__ is not None else {},
skill_name,
{'__event_call__': __event_call__},
overview=True,
)
if not skill:
return JSONCodec.dumps({'error': f"Skill '{id}' not found"})
@ -3545,8 +3552,7 @@ async def view_skill(
{
'id': skill.id,
'name': snapshot['name'],
**skill_content_page(snapshot['content']),
**bounded_skill_manifest(file_summaries(snapshot['data']['files'])),
**skill_overview(snapshot['content'], file_summaries(snapshot['data']['files'])),
},
ensure_ascii=False,
)
@ -3569,7 +3575,7 @@ async def read_skill_file(
:param id: Skill ID.
:param path: Relative path within the skill.
:param offset: Character offset for paging text.
:param max_chars: Maximum characters to return, up to 100000.
:param max_chars: Maximum characters to return, up to 20000 (default 10000).
"""
try:
from types import SimpleNamespace

View file

@ -2872,7 +2872,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.skill_files import format_skill_content, skill_overview
from open_webui.utils.terminals import (
format_terminal_skill_context,
format_terminal_skill_manifest_entry,
@ -2909,13 +2909,13 @@ async def process_chat_payload(request, form_data, user, metadata, model):
continue
skill_versions[skill.id] = version_id
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
overview = skill_overview(
snapshot['content'],
[f['path'] for f in snapshot['data']['files'] if f['path'] != 'SKILL.md'],
tools_enabled=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'])
root_content = format_skill_content(overview, skill.id, skill_tools_enabled)
resources = '\n'.join(overview['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)
@ -2923,8 +2923,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if resources
else ''
)
if manifest.get('notice'):
resource_hint += '\n' + manifest['notice']
if overview.get('notice'):
resource_hint += '\n' + overview['notice']
form_data['messages'] = add_or_update_system_message(
f'<skill id="{skill.id}" name="{skill.name}">\n'
f'{root_content}{resource_hint}\n</skill>',
@ -2956,7 +2956,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
sid = skill['id']
if sid in mentioned_skill_ids or not use_builtin_tools or not model_builtin_tools.get('skills', True):
skill_name = unquote(sid.removeprefix(terminal_skill_prefix))
loaded = await get_terminal_skill(request, user.model_dump(), metadata, skill_name, extra_params)
loaded = await get_terminal_skill(
request,
user.model_dump(),
metadata,
skill_name,
extra_params,
overview=True,
tools_enabled=use_builtin_tools and model_builtin_tools.get('skills', True),
)
if loaded:
form_data['messages'] = add_or_update_system_message(
format_terminal_skill_context(

View file

@ -19,7 +19,10 @@ 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_CONTENT_MAX_CHARS = 20_000
SKILL_INJECTION_MAX_CHARS = 100_000
SKILL_PREVIEW_MAX_CHARS = 5_000
SKILL_OVERVIEW_LIST_MAX_CHARS = 2_500
SKILL_MANIFEST_MAX_ENTRIES = 50
SKILL_MANIFEST_MAX_CHARS = 5_000
@ -30,11 +33,11 @@ def skill_content_page(content: str, offset: int = 0, max_chars: int = SKILL_CON
return {'content': content[offset:end], 'next_offset': end if end < len(content) else None}
def bounded_skill_manifest(entries: list, field: str = 'files') -> dict:
def bounded_skill_manifest(entries: list, field: str = 'files', max_chars: int = SKILL_MANIFEST_MAX_CHARS) -> 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:
if size + entry_size > max_chars:
break
bounded.append(entry)
size += entry_size
@ -44,13 +47,73 @@ def bounded_skill_manifest(entries: list, field: str = 'files') -> dict:
return result
def skill_heading_index(content: str) -> dict:
headings, size, offset = [], 2, 0
frontmatter_match = re.match(r'\A---[ \t]*\r?\n.*?\r?\n---[ \t]*(?:\r?\n|$)', content, re.DOTALL)
body_start = frontmatter_match.end() if frontmatter_match else 0
fence = None
for line in content.splitlines(keepends=True):
line_offset = offset
offset += len(line)
if line_offset < body_start:
continue
text = line.rstrip('\r\n')
marker = re.match(r' {0,3}(`{3,}|~{3,})(.*)$', text)
if fence:
if marker and marker[1][0] == fence[0] and len(marker[1]) >= len(fence) and not marker[2].strip():
fence = None
continue
if marker and (marker[1][0] == '~' or '`' not in marker[2]):
fence = marker[1]
continue
heading = re.match(r' {0,3}(#{1,6})(?:[ \t]+(.*)|$)', text)
if heading:
title = re.sub(r'(?:^|[ \t]+)#+[ \t]*$', '', heading[2] or '').strip()
entry = {'title': title, 'level': len(heading[1]), 'offset': line_offset}
entry_size = len(json.dumps(entry, ensure_ascii=False)) + (2 if headings else 0)
if size + entry_size > SKILL_OVERVIEW_LIST_MAX_CHARS:
return {'headings': headings, 'headings_truncated': True}
headings.append(entry)
size += entry_size
return {'headings': headings, 'headings_truncated': False}
def skill_overview(content: str, files: list, field: str = 'files', tools_enabled: bool = True) -> dict:
if not tools_enabled:
return {
'content': content[:SKILL_INJECTION_MAX_CHARS],
'next_offset': SKILL_INJECTION_MAX_CHARS if len(content) > SKILL_INJECTION_MAX_CHARS else None,
**bounded_skill_manifest(files, field),
}
large = len(content) > SKILL_CONTENT_MAX_CHARS
overview = {
**skill_content_page(content, max_chars=SKILL_PREVIEW_MAX_CHARS if large else SKILL_CONTENT_MAX_CHARS),
**bounded_skill_manifest(files, field, SKILL_OVERVIEW_LIST_MAX_CHARS),
}
if large:
overview.update(skill_heading_index(content))
overview['reading_hint'] = (
'This is an opening preview, not the complete instructions. Read applicable sections, including mandatory '
'rules, with read_skill_file using the skill id, path="SKILL.md", a heading offset, and max_chars up to 20000. '
'Offsets count characters in the original file. Use next_offset for additional text when needed.'
)
return overview
def format_skill_content(page: dict, skill_id: str, tools_enabled: bool) -> str:
content = page['content']
if tools_enabled and 'headings' in page:
content += '\nSection index: ' + json.dumps(page['headings'], ensure_ascii=False)
if page.get('headings_truncated'):
content += '\nAdditional headings omitted.'
content += f'\n{page["reading_hint"]} Skill ID: {json.dumps(skill_id)}.'
content += f'\nnext_offset: {page["next_offset"]}'
return 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'\nRead additional text when needed with read_skill_file(id={json.dumps(skill_id)}, '
f'path="SKILL.md", offset={page["next_offset"]}).'
)
return content

View file

@ -14,6 +14,7 @@ from open_webui.utils.skill_files import (
bounded_skill_manifest,
format_skill_content,
skill_content_page,
skill_overview,
)
TERMINAL_CONTEXT_HEADER = 'X-Terminal-Context-Id'
@ -242,6 +243,8 @@ async def get_terminal_skill(
offset: int = 0,
max_chars: int = SKILL_CONTENT_MAX_CHARS,
refresh: bool = True,
overview: bool = False,
tools_enabled: bool = True,
) -> dict | None:
skill = await get_terminal_json(
request, user, metadata, f'/skills/read?name={quote(skill_name, safe="")}', extra_params
@ -266,9 +269,15 @@ async def get_terminal_skill(
return {
'name': skill.get('name'),
'description': skill.get('description'),
**skill_content_page(skill['content'], offset, max_chars),
'directory': directory,
**bounded_skill_manifest(resources, 'resources'),
**(
skill_overview(skill['content'], resources, 'resources', tools_enabled)
if overview
else {
**skill_content_page(skill['content'], offset, max_chars),
**bounded_skill_manifest(resources, 'resources'),
}
),
}