Coverage for src / agent / skills / context_provider.py: 82%
152 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
1# Copyright 2025-2026 Microsoft Corporation
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Skill context provider for dynamic instruction injection.
17This module provides progressive skill documentation using Agent Framework's
18ContextProvider pattern. Skill documentation is injected on-demand based on
19user queries and trigger matching, avoiding constant token overhead.
20"""
22import logging
23import re
24from collections.abc import MutableSequence
25from typing import Any
27from agent_framework import ChatMessage, Context, ContextProvider
29from agent.skills.documentation_index import SkillDocumentationIndex
31logger = logging.getLogger(__name__)
34class SkillContextProvider(ContextProvider):
35 """Progressive skill documentation with on-demand registry.
37 Implements three-tier progressive disclosure:
38 1. Minimal breadcrumb (~10 tokens) when skills exist but don't match
39 2. Full registry (10-15 tokens/skill) when user asks about capabilities
40 3. Full documentation (hundreds of tokens) when triggers match
42 Example:
43 >>> skill_docs = SkillDocumentationIndex()
44 >>> provider = SkillContextProvider(skill_docs, max_skills=3)
45 >>> agent = chat_client.create_agent(
46 ... name="Agent",
47 ... instructions="...",
48 ... context_providers=[provider]
49 ... )
50 """
52 def __init__(
53 self,
54 skill_docs: SkillDocumentationIndex,
55 memory_manager: Any | None = None,
56 max_skills: int = 3,
57 max_all_skills: int = 10,
58 ):
59 """Initialize skill context provider.
61 Args:
62 skill_docs: SkillDocumentationIndex with loaded skill metadata
63 memory_manager: Optional memory manager for conversation context (unused)
64 max_skills: Maximum number of skills to inject when matched (default: 3)
65 max_all_skills: Cap for "show all skills" to prevent overflow (default: 10)
66 """
67 self.skill_docs = skill_docs
68 self.memory_manager = memory_manager # For conversation context (future use)
69 self.max_skills = max_skills
70 self.max_all_skills = max_all_skills
72 async def invoking(
73 self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any
74 ) -> Context:
75 """Inject skill documentation based on request relevance.
77 Args:
78 messages: Current conversation messages
79 **kwargs: Additional context
81 Returns:
82 Context with appropriate skill documentation
83 """
84 # 1. Extract current user message
85 current_message = self._get_latest_user_message(messages)
86 if not current_message:
87 return Context()
89 # 2. Check if user is asking about capabilities
90 if self._wants_skill_info(current_message):
91 return self._inject_skill_registry()
93 # 3. Check for "show all skills" escape hatch
94 if self._wants_all_skills(current_message):
95 return self._inject_all_skills_capped()
97 # 4. Match skills based on current message
98 relevant_skills = self._match_skills_safely(current_message.lower())
100 # 5. Build response based on matches
101 if relevant_skills:
102 # Inject full documentation for matched skills
103 docs = self._build_skill_documentation(relevant_skills[: self.max_skills])
104 logger.debug(
105 f"Injecting {len(relevant_skills[:self.max_skills])} skill(s) documentation"
106 )
107 return Context(instructions=docs)
108 elif self.skill_docs.has_skills():
109 # Hybrid Tier-1 approach:
110 # - If any skill has explicit triggers (beyond auto-added name) → minimal breadcrumb
111 # - If no skills have explicit triggers → full registry (better discovery)
112 if self._any_skill_has_explicit_triggers():
113 # Minimal breadcrumb: skill count only
114 breadcrumb = f"[{self.skill_docs.count()} skills available]"
115 logger.debug(
116 f"No skill match - injecting minimal breadcrumb ({self.skill_docs.count()} skills with triggers)"
117 )
118 return Context(instructions=breadcrumb)
119 else:
120 # No triggers defined yet → use registry for LLM discovery
121 logger.debug(
122 "No skill match - injecting full registry (no skills have structured triggers)"
123 )
124 return self._inject_skill_registry()
125 else:
126 # No skills installed - inject nothing
127 return Context()
129 def _any_skill_has_explicit_triggers(self) -> bool:
130 """Check if any skill has explicit structured triggers (beyond skill name).
132 Returns:
133 True if at least one skill has more than just its name as a keyword,
134 or has verbs or patterns defined.
135 """
136 for skill in self.skill_docs.get_all_metadata():
137 triggers = skill.get("triggers", {})
138 keywords = triggers.get("keywords", [])
139 # The first keyword is always the skill name (auto-added by model_post_init)
140 # Explicit triggers exist if there are additional keywords, verbs, or patterns
141 if len(keywords) > 1 or triggers.get("verbs") or triggers.get("patterns"):
142 return True
143 return False
145 def _inject_skill_registry(self) -> Context:
146 """Inject skill registry with brief descriptions for LLM-driven discovery."""
147 lines = ["## Available Skills\n"]
148 for skill in self.skill_docs.get_all_metadata():
149 # Include brief description for discoverability (10-15 tokens per skill)
150 brief = skill["brief_description"][:80] # Slightly longer for clarity
151 lines.append(f"- **{skill['name']}**: {brief}")
153 lines.append(
154 "\nWhen you need current information, specialized data, or capabilities "
155 "beyond your knowledge, consider whether one of these skills could help. "
156 "Skill scripts are available via the script_run tool."
157 )
158 registry_text = "\n".join(lines)
159 logger.debug(f"Injecting skill registry with {self.skill_docs.count()} skills")
160 return Context(instructions=registry_text)
162 def _wants_skill_info(self, message: str) -> bool:
163 """Check if user is asking about capabilities."""
164 info_patterns = [
165 r"\bwhat.*(?:can|could).*(?:you|u).*do\b",
166 r"\b(?:show|list).*capabilities\b",
167 r"\bwhat.*skills?\b",
168 ]
169 message_lower = message.lower()
170 return any(re.search(pattern, message_lower) for pattern in info_patterns)
172 def _wants_all_skills(self, message: str) -> bool:
173 """Check if user wants to see all skill documentation."""
174 all_patterns = [
175 r"\bshow.*all.*skills?\b",
176 r"\blist.*all.*skills?\b",
177 r"\ball.*skill.*(?:documentation|docs)\b",
178 ]
179 message_lower = message.lower()
180 return any(re.search(pattern, message_lower) for pattern in all_patterns)
182 def _match_skills_safely(self, context: str) -> list[dict]:
183 """Match skills with word boundaries and error handling.
185 Args:
186 context: User message text (lowercase)
188 Returns:
189 List of matched skill metadata dictionaries
190 """
191 matched = []
192 seen = set()
194 for skill in self.skill_docs.get_all_metadata():
195 skill_id = skill["name"]
196 if skill_id in seen:
197 continue
199 skill_name_lower = skill["name"].lower()
200 triggers = skill.get("triggers", {})
202 # Check if triggers dict is effectively empty (no keywords, verbs, or patterns)
203 has_triggers = bool(
204 triggers.get("keywords") or triggers.get("verbs") or triggers.get("patterns")
205 )
207 # If no triggers defined, only match by skill name (restrictive fallback)
208 if not has_triggers:
209 try:
210 if re.search(rf"\b{re.escape(skill_name_lower)}\b", context):
211 matched.append(skill)
212 seen.add(skill_id)
213 except re.error as e:
214 logger.warning(f"Regex error matching skill name '{skill_name_lower}': {e}")
215 continue
217 # Strategy 1: Skill name mentioned (word boundary)
218 try:
219 if re.search(rf"\b{re.escape(skill_name_lower)}\b", context):
220 matched.append(skill)
221 seen.add(skill_id)
222 continue
223 except re.error as e:
224 logger.warning(f"Regex error matching skill name '{skill_name_lower}': {e}")
226 # Strategy 2: Keyword triggers (word boundary)
227 for keyword in triggers.get("keywords", []):
228 try:
229 if re.search(rf"\b{re.escape(keyword.lower())}\b", context):
230 matched.append(skill)
231 seen.add(skill_id)
232 break
233 except re.error as e:
234 logger.warning(f"Regex error matching keyword '{keyword}': {e}")
236 if skill_id in seen:
237 continue
239 # Strategy 3: Verb triggers (word boundary)
240 for verb in triggers.get("verbs", []):
241 try:
242 if re.search(rf"\b{re.escape(verb.lower())}\b", context):
243 matched.append(skill)
244 seen.add(skill_id)
245 break
246 except re.error as e:
247 logger.warning(f"Regex error matching verb '{verb}': {e}")
249 if skill_id in seen:
250 continue
252 # Strategy 4: Pattern matching (with error handling)
253 for pattern in triggers.get("patterns", []):
254 try:
255 if re.search(pattern, context, re.IGNORECASE):
256 matched.append(skill)
257 seen.add(skill_id)
258 break
259 except re.error as e:
260 logger.warning(f"Invalid regex pattern for {skill_id}: {pattern} - {e}")
262 return matched
264 def _build_skill_documentation(self, skills: list[dict]) -> str:
265 """Build full documentation for matched skills.
267 Args:
268 skills: List of skill metadata dictionaries
270 Returns:
271 Formatted skill documentation string
272 """
273 docs = ["## Relevant Skill Documentation\n"]
274 for skill in skills:
275 docs.append(f"### {skill['name']}\n")
276 docs.append(skill.get("instructions", ""))
277 docs.append("")
278 return "\n".join(docs)
280 def _inject_all_skills_capped(self) -> Context:
281 """Inject skill documentation with cap to avoid context overflow."""
282 all_skills = self.skill_docs.get_all_metadata()
284 if len(all_skills) <= self.max_all_skills:
285 # Show all if under cap
286 docs = self._build_skill_documentation(all_skills)
287 else:
288 # Show capped list with note
289 docs = self._build_skill_documentation(all_skills[: self.max_all_skills])
290 docs += f"\n\n*Showing {self.max_all_skills} of {len(all_skills)} skills. "
291 docs += "Ask about specific skills for more details.*"
293 logger.debug(f"Injecting all skills (capped at {self.max_all_skills})")
294 return Context(instructions=docs)
296 def _get_latest_user_message(self, messages: ChatMessage | MutableSequence[ChatMessage]) -> str:
297 """Extract the latest user message.
299 Args:
300 messages: Current conversation messages
302 Returns:
303 Latest user message text or empty string
304 """
305 msg_list = messages if isinstance(messages, MutableSequence) else [messages]
307 # Find user message (robust extraction like MemoryContextProvider)
308 for msg in reversed(msg_list):
309 role = str(getattr(msg, "role", ""))
310 if "user" in role.lower():
311 return self._extract_message_text(msg)
313 return ""
315 def _extract_message_text(self, msg: ChatMessage) -> str:
316 """Extract text from a ChatMessage (copied from MemoryContextProvider).
318 Args:
319 msg: Chat message
321 Returns:
322 Message text or empty string
323 """
324 # Try different attributes the message might have
325 if hasattr(msg, "text"):
326 return str(msg.text)
327 elif hasattr(msg, "content"):
328 content = msg.content
329 if isinstance(content, str):
330 return content
331 elif isinstance(content, list):
332 # Handle list of content items
333 texts = []
334 for item in content:
335 if hasattr(item, "text"):
336 texts.append(str(item.text))
337 elif isinstance(item, dict) and "text" in item:
338 texts.append(str(item["text"]))
339 return " ".join(texts) if texts else ""
340 else:
341 return str(content)
342 else:
343 # Fallback to string representation
344 return str(msg) if msg else ""