refac
Some checks failed
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled

This commit is contained in:
Timothy Jaeryang Baek 2026-10-10 01:28:36 +04:00
parent 056e2ac37e
commit d3acc2706d
5 changed files with 113 additions and 20 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
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,
)

View file

@ -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'<skill id="{skill.id}" version_id="{version_id}" name="{skill.name}">\n{root_content}{resource_hint}\n</skill>',
f'<skill id="{skill.id}" version_id="{version_id}" name="{skill.name}">\n'
f'{root_content}{resource_hint}\n</skill>',
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,
)

View file

@ -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):

View file

@ -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 name="{skill.get("name") or ""}">', skill.get('content') or '']
parts = [f'<skill name="{skill.get("name") or ""}">', format_skill_content(skill, skill_id, tools_enabled)]
if skill.get('directory'):
parts.append(f'<directory>{skill["directory"]}</directory>')
if resources:
parts.append('<resources>')
parts.extend(f'<file>{resource}</file>' for resource in resources)
parts.append('</resources>')
if skill.get('notice'):
parts.append(skill['notice'])
parts.append('</skill>')
return '\n'.join(parts)

View file

@ -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(