Coverage for open_webui/utils/skills.py: 16%

61 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 05:07 +0000

1"""Helpers for chat skill mentions and skill-authoring slash commands.""" 

2 

3import re 

4 

5from open_webui.utils.misc import get_content_from_message, set_last_user_message_content 

6 

7 

8# Match DB skill IDs and terminal skill IDs created by the $ picker. 

9SKILL_ID_RE = r'(?:[a-z0-9_-]+|terminal:[^|>\s]+)' 

10SKILL_MENTION_RE = re.compile(rf'<(?:\$({SKILL_ID_RE})(?:\|[^>]*)?|/({SKILL_ID_RE})\|[^>]*)>') 

11 

12 

13def _get_text_parts(message: dict) -> list[str]: 

14 """Return all text segments from a message's content.""" 

15 content = message.get('content') 

16 if isinstance(content, str): 

17 return [content] 

18 if isinstance(content, list): 

19 return [p.get('text', '') for p in content if isinstance(p, dict) and p.get('type') == 'text'] 

20 return [] 

21 

22 

23def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]: 

24 """Extract skill IDs from <$skillId|label> and </skillId|label> mention tags.""" 

25 ids: set[str] = set() 

26 for message in messages: 

27 for text in _get_text_parts(message): 

28 ids.update(m.group(1) or m.group(2) for m in SKILL_MENTION_RE.finditer(text)) 

29 return ids 

30 

31 

32SKILL_MENTION_STRIP_RE = re.compile(rf'<(?:\$({SKILL_ID_RE})(?:\|([^>]*))?|/({SKILL_ID_RE})\|([^>]*))>') 

33 

34 

35SKILLS_CREATE_RE = re.compile(r'^/skills:create(?:\s+(.*))?$', re.IGNORECASE | re.DOTALL) 

36 

37OPEN_WEBUI_SKILL_AUTHORING_STANDARDS = """\ 

38Follow the Open WebUI skill-authoring standards: 

39 

40Frontmatter: 

41- name: lowercase-hyphenated, <=64 chars, no spaces. 

42- description: one sentence, <=60 characters, ends with a period. State the 

43 capability, not the implementation. Do not repeat the skill name. Avoid 

44 marketing words like powerful, comprehensive, seamless, advanced, or robust. 

45 Count the characters before saving. 

46- version: 0.1.0. 

47- platforms: declare [macos], [linux], or [windows] only when the skill uses 

48 OS-bound primitives. Omit it for portable skills. 

49 

50Body section order: 

511. "# <Human Title>" plus a short intro covering what it does, what it does not 

52 do, and important dependency assumptions. 

532. "## When to Use" with concrete trigger phrases. 

543. "## Prerequisites" with exact env vars, credentials, install steps, or "None". 

554. "## How to Run" with the canonical workflow framed through the available tools. 

565. "## Quick Reference" with flat commands, routes, files, or APIs. 

576. "## Procedure" with numbered, copy-paste-exact steps. 

587. "## Pitfalls" with known limits and failure modes. 

598. "## Verification" with one focused check that proves the skill works. 

60 

61Tool framing: 

62- Reference available tools by name in backticks, including `run_command`, 

63 `write_file`, and `view_skill` when relevant. 

64- Frame shell work as "run through `run_command`". 

65- Prefer available read/search tools in prose over raw shell utilities when a 

66 tool exists. 

67- Third-party CLIs are fine inside procedures or scripts, but explain that the 

68 agent invokes them through `run_command`. 

69 

70Quality bar: 

71- Prefer exact commands, routes, file paths, function names, config keys, and 

72 error text found verbatim in the sources. Do not invent flags, paths, APIs, 

73 or behavior. 

74- Keep SKILL.md tight and scannable: about 100 lines for a simple workflow, 

75 about 200 for a complex one. 

76- Do not create a router/index/hub skill that only points at other skills. 

77- Put larger reusable scripts in `scripts/`, detailed docs in `references/`, 

78 reusable outputs in `templates/`, and binary or visual assets in `assets/`.""" 

79 

80 

81def _build_skill_create_prompt(user_request: str) -> str: 

82 req = (user_request or '').strip() 

83 if not req: 

84 req = ( 

85 'the workflow we just went through in this conversation - review the ' 

86 'steps taken and distill them into a reusable skill' 

87 ) 

88 return ( 

89 '[/skills:create] The user wants you to create a reusable Open WebUI skill ' 

90 'for the selected Open Terminal and save it.\n\n' 

91 f'THE REQUEST:\n{req}\n\n' 

92 'The request is open-ended and may mix SOURCES to gather (directories, ' 

93 'file paths, URLs, what we just did, pasted notes) and REQUIREMENTS that ' 

94 'shape the skill (focus, exclusions, scope, naming, style, constraints). ' 

95 'Treat every part of the request as load-bearing. Prose after a path or ' 

96 'URL is not incidental; it is authoring guidance. Never fetch the first ' 

97 'source and ignore the rest.\n\n' 

98 'Do this:\n' 

99 '1. Gather every source the user named with the tools you already have: ' 

100 'available file/search/web tools, the current conversation if they refer ' 

101 'to what just happened, pasted text as-is, and `run_command` for shell ' 

102 'work. If scope is ambiguous, make a reasonable choice and note it; do ' 

103 'not stall.\n' 

104 '2. Apply every requirement, focus, and constraint in the request to what ' 

105 'the skill covers and emphasizes.\n' 

106 '3. Author one SKILL.md using the standards below.\n' 

107 '4. Save with the selected terminal tools under the terminal root cwd: ' 

108 '<terminal-root-cwd>/.agents/skills/<skill-name>/SKILL.md. Do not save ' 

109 'under a browsed subfolder or transient shell pwd. If the terminal root ' 

110 'is unavailable, use `pwd` as the fallback. If the skill needs ' 

111 'supporting files, add them under scripts/, references/, templates/, ' 

112 'or assets/.\n\n' 

113 f'{OPEN_WEBUI_SKILL_AUTHORING_STANDARDS}\n\n' 

114 'When done, tell the user the skill name, location, and one-line summary.' 

115 ) 

116 

117 

118def _message_has_real_content(message: dict) -> bool: 

119 text = (get_content_from_message(message) or '').strip() 

120 return bool(text or message.get('tool_calls') or message.get('output')) 

121 

122 

123def has_prior_real_chat_content(messages: list[dict]) -> bool: 

124 last_user_idx = next( 

125 (idx for idx in range(len(messages) - 1, -1, -1) if messages[idx].get('role') == 'user'), 

126 len(messages), 

127 ) 

128 return any( 

129 message.get('role') == 'user' and _message_has_real_content(message) for message in messages[:last_user_idx] 

130 ) 

131 

132 

133def _build_skill_create_gate_prompt(reason: str = 'empty_chat') -> str: 

134 if reason == 'disabled': 

135 return ( 

136 '[/skills:create] Skill creation requires a selected Open Terminal. ' 

137 'Explain this briefly and do not try to create or update a skill.' 

138 ) 

139 return ( 

140 '[/skills:create] The user tried to create a skill before this chat had ' 

141 'any prior real content. Explain briefly that skill creation needs an ' 

142 'existing chat with the workflow or source material already in it, then ' 

143 'ask them to continue in a chat with content first.' 

144 ) 

145 

146 

147def apply_skills_create_prompt( 

148 messages: list[dict], 

149 *, 

150 allowed: bool, 

151 denial_reason: str = 'empty_chat', 

152) -> bool: 

153 for message in reversed(messages): 

154 if message.get('role') != 'user': 

155 continue 

156 text = get_content_from_message(message) or '' 

157 match = SKILLS_CREATE_RE.match(text.strip()) 

158 if not match: 

159 return False 

160 set_last_user_message_content( 

161 _build_skill_create_prompt(match.group(1) or '') 

162 if allowed 

163 else _build_skill_create_gate_prompt(denial_reason), 

164 messages, 

165 ) 

166 return True 

167 return False 

168 

169 

170def strip_skill_mentions(messages: list[dict], skill_ids: set[str]) -> None: 

171 """Replace mentions of resolved skills with their label, preserving all other text.""" 

172 

173 def label(match): 

174 if (match.group(1) or match.group(3)) not in skill_ids: 

175 return match.group(0) 

176 return match.group(2) or match.group(4) or '' 

177 

178 for message in messages: 

179 content = message.get('content') 

180 if isinstance(content, str) and SKILL_MENTION_STRIP_RE.search(content): 

181 message['content'] = SKILL_MENTION_STRIP_RE.sub(label, content) 

182 elif isinstance(content, list): 

183 for part in content: 

184 if isinstance(part, dict) and part.get('type') == 'text': 

185 text = part.get('text', '') 

186 if SKILL_MENTION_STRIP_RE.search(text): 

187 part['text'] = SKILL_MENTION_STRIP_RE.sub(label, text)