mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-10 15:56:03 +00:00
feat(agent-runner): expose skill resources through host context
This commit is contained in:
@@ -71,13 +71,6 @@ def supports_skill_authoring(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
return bool(descriptor.capabilities.get('skill_authoring', False))
|
||||
|
||||
|
||||
def supports_skill_injection(descriptor: AgentRunnerDescriptor | None) -> bool:
|
||||
"""Return whether the runner wants the Host skill index in the effective prompt."""
|
||||
if descriptor is None:
|
||||
return False
|
||||
return bool(descriptor.capabilities.get('skill_injection', False))
|
||||
|
||||
|
||||
def extract_prompt_config(
|
||||
descriptor: AgentRunnerDescriptor | None,
|
||||
runner_config: dict[str, typing.Any],
|
||||
|
||||
@@ -84,6 +84,14 @@ class KnowledgeBaseResource(typing.TypedDict):
|
||||
kb_type: str | None
|
||||
|
||||
|
||||
class SkillResource(typing.TypedDict):
|
||||
"""Skill resource payload."""
|
||||
|
||||
skill_name: str
|
||||
display_name: str | None
|
||||
description: str | None
|
||||
|
||||
|
||||
class FileResource(typing.TypedDict):
|
||||
"""File resource payload."""
|
||||
|
||||
@@ -106,6 +114,7 @@ class AgentResources(typing.TypedDict):
|
||||
models: list[ModelResource]
|
||||
tools: list[ToolResource]
|
||||
knowledge_bases: list[KnowledgeBaseResource]
|
||||
skills: list[SkillResource]
|
||||
files: list[FileResource]
|
||||
storage: StorageResource
|
||||
platform_capabilities: dict[str, typing.Any]
|
||||
|
||||
@@ -95,6 +95,9 @@ class ResourcePolicy(pydantic.BaseModel):
|
||||
allowed_kb_uuids: list[str] | None = None
|
||||
"""Additional knowledge base UUID grants. None means no additional KB grants."""
|
||||
|
||||
allowed_skill_names: list[str] | None = None
|
||||
"""Allowed skill names. None means all currently visible skills are allowed."""
|
||||
|
||||
allow_plugin_storage: bool = True
|
||||
"""Whether plugin storage is allowed."""
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from .result_normalizer import AgentResultNormalizer
|
||||
from .run_journal import AgentRunJournal, MAX_ARTIFACT_INLINE_BYTES as _MAX_ARTIFACT_INLINE_BYTES
|
||||
from .session_registry import AgentRunSessionRegistry, get_session_registry
|
||||
from .state_scope import build_state_context
|
||||
from ...provider.tools.loaders import skill as skill_loader
|
||||
|
||||
|
||||
MAX_ARTIFACT_INLINE_BYTES = _MAX_ARTIFACT_INLINE_BYTES
|
||||
@@ -86,6 +87,13 @@ class AgentRunOrchestrator:
|
||||
|
||||
session_query_id = None
|
||||
if adapter_context:
|
||||
query = adapter_context.get('_query')
|
||||
if query is not None:
|
||||
skill_loader.restore_activated_skills_from_state(
|
||||
self.ap,
|
||||
query,
|
||||
context.get('state', {}),
|
||||
)
|
||||
session_query_id = adapter_context.get('query_id')
|
||||
if 'params' in adapter_context:
|
||||
context['adapter']['extra']['params'] = adapter_context['params']
|
||||
@@ -175,11 +183,13 @@ class AgentRunOrchestrator:
|
||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||
"""Run an AgentRunner from the current Pipeline Query entry point."""
|
||||
plan = self.query_bridge.build_plan(query)
|
||||
adapter_context = dict(plan.adapter_context)
|
||||
adapter_context['_query'] = query
|
||||
async for result in self.run(
|
||||
plan.event,
|
||||
plan.binding,
|
||||
bound_plugins=plan.bound_plugins,
|
||||
adapter_context=plan.adapter_context,
|
||||
adapter_context=adapter_context,
|
||||
):
|
||||
yield result
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ class QueryEntryAdapter:
|
||||
allowed_model_uuids=cls._extract_allowed_models(query),
|
||||
allowed_tool_names=cls._extract_allowed_tools(query),
|
||||
allowed_kb_uuids=cls._extract_allowed_kbs(query),
|
||||
allowed_skill_names=cls._extract_allowed_skills(query),
|
||||
)
|
||||
|
||||
# Build state policy
|
||||
@@ -583,3 +584,19 @@ class QueryEntryAdapter:
|
||||
if kb_uuids:
|
||||
return kb_uuids
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extract_allowed_skills(
|
||||
cls,
|
||||
query: pipeline_query.Query,
|
||||
) -> list[str] | None:
|
||||
"""Extract pipeline-visible skill names from query."""
|
||||
variables = getattr(query, 'variables', None)
|
||||
if not variables or '_pipeline_bound_skills' not in variables:
|
||||
return None
|
||||
bound_skills = variables.get('_pipeline_bound_skills')
|
||||
if bound_skills is None:
|
||||
return None
|
||||
if not isinstance(bound_skills, list):
|
||||
return []
|
||||
return [str(skill_name) for skill_name in bound_skills if skill_name]
|
||||
|
||||
@@ -10,6 +10,7 @@ from .context_builder import (
|
||||
ModelResource,
|
||||
ToolResource,
|
||||
KnowledgeBaseResource,
|
||||
SkillResource,
|
||||
StorageResource,
|
||||
)
|
||||
from . import config_schema
|
||||
@@ -36,6 +37,7 @@ class AgentResourceBuilder:
|
||||
- ModelResource: model_id, model_type, provider
|
||||
- ToolResource: tool_name, tool_type, description
|
||||
- KnowledgeBaseResource: kb_id, kb_name, kb_type
|
||||
- SkillResource: skill_name, display_name, description
|
||||
- StorageResource: plugin_storage, workspace_storage
|
||||
"""
|
||||
|
||||
@@ -81,12 +83,16 @@ class AgentResourceBuilder:
|
||||
knowledge_bases = await self._build_knowledge_bases_from_binding(
|
||||
manifest_perms, resource_policy, descriptor, runner_config
|
||||
)
|
||||
skills = self._build_skills_from_binding(
|
||||
resource_policy, descriptor
|
||||
)
|
||||
storage = self._build_storage_from_binding(manifest_perms, binding)
|
||||
|
||||
return {
|
||||
'models': models,
|
||||
'tools': tools,
|
||||
'knowledge_bases': knowledge_bases,
|
||||
'skills': skills,
|
||||
'files': [], # Files are populated at runtime
|
||||
'storage': storage,
|
||||
'platform_capabilities': {}, # Reserved for EBA
|
||||
@@ -193,6 +199,36 @@ class AgentResourceBuilder:
|
||||
|
||||
return kb_resources
|
||||
|
||||
def _build_skills_from_binding(
|
||||
self,
|
||||
resource_policy: typing.Any,
|
||||
descriptor: AgentRunnerDescriptor,
|
||||
) -> list[SkillResource]:
|
||||
"""Build pipeline-visible skill resource facts."""
|
||||
if not config_schema.supports_skill_authoring(descriptor):
|
||||
return []
|
||||
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return []
|
||||
|
||||
loaded_skills = getattr(skill_mgr, 'skills', {}) or {}
|
||||
allowed_names = resource_policy.allowed_skill_names
|
||||
if allowed_names is None:
|
||||
names = sorted(loaded_skills.keys())
|
||||
else:
|
||||
names = sorted(name for name in allowed_names if name in loaded_skills)
|
||||
|
||||
skills: list[SkillResource] = []
|
||||
for skill_name in names:
|
||||
skill_data = loaded_skills.get(skill_name) or {}
|
||||
skills.append({
|
||||
'skill_name': skill_name,
|
||||
'display_name': skill_data.get('display_name') or skill_data.get('name') or skill_name,
|
||||
'description': skill_data.get('description') or None,
|
||||
})
|
||||
return skills
|
||||
|
||||
def _build_storage_from_binding(
|
||||
self,
|
||||
manifest_perms: dict[str, list[str]],
|
||||
|
||||
@@ -140,6 +140,7 @@ class AgentRunSessionRegistry:
|
||||
'model': {m.get('model_id') for m in resources.get('models', [])},
|
||||
'tool': {t.get('tool_name') for t in resources.get('tools', [])},
|
||||
'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])},
|
||||
'skill': {s.get('skill_name') for s in resources.get('skills', [])},
|
||||
'file': {f.get('file_id') for f in resources.get('files', [])},
|
||||
}
|
||||
|
||||
@@ -197,7 +198,7 @@ class AgentRunSessionRegistry:
|
||||
authorized_ids = authorization['authorized_ids']
|
||||
resources = authorization['resources']
|
||||
|
||||
if resource_type in ('model', 'tool', 'knowledge_base', 'file'):
|
||||
if resource_type in ('model', 'tool', 'knowledge_base', 'skill', 'file'):
|
||||
return resource_id in authorized_ids.get(resource_type, set())
|
||||
|
||||
if resource_type == 'storage':
|
||||
|
||||
@@ -171,7 +171,6 @@ class PreProcessor(stage.PipelineStage):
|
||||
config_schema.supports_skill_authoring(descriptor)
|
||||
and getattr(self.ap, 'skill_service', None) is not None
|
||||
)
|
||||
inject_skill_context = config_schema.supports_skill_injection(descriptor)
|
||||
llm_model = None
|
||||
if uses_host_models:
|
||||
primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config)
|
||||
@@ -350,19 +349,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.prompt.messages = event_ctx.event.default_prompt
|
||||
query.messages = event_ctx.event.prompt
|
||||
|
||||
# =========== Skill awareness for capable runners ===========
|
||||
# The actual activation goes through the ``activate`` Tool Call so the
|
||||
# LLM doesn't see full SKILL.md instructions until it commits to a
|
||||
# skill (Claude Code's progressive disclosure). But the LLM still has
|
||||
# to KNOW which skills exist to make that choice, so we:
|
||||
# 1. resolve the pipeline's bound skills and stash them in
|
||||
# ``query.variables['_pipeline_bound_skills']`` for downstream
|
||||
# visibility checks (skill loader, native exec workdir);
|
||||
# 2. inject a short ``Available Skills`` index (name + description
|
||||
# only) into the system prompt. The contributor's original PR
|
||||
# relied on this injection; without it the LLM never discovers
|
||||
# the skills are there and just calls native tools instead.
|
||||
if inject_skill_context and self.ap.skill_mgr:
|
||||
if include_skill_authoring and getattr(self.ap, 'skill_mgr', None) is not None:
|
||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
||||
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
||||
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
||||
@@ -374,43 +361,4 @@ class PreProcessor(stage.PipelineStage):
|
||||
|
||||
query.variables['_pipeline_bound_skills'] = bound_skills
|
||||
|
||||
skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition(
|
||||
bound_skills=bound_skills,
|
||||
)
|
||||
if skill_addition:
|
||||
# Append to the first system message; create one if the
|
||||
# prompt has none. Handles both plain-string and
|
||||
# content-element (list) message bodies.
|
||||
if query.prompt.messages and query.prompt.messages[0].role == 'system':
|
||||
head = query.prompt.messages[0]
|
||||
if isinstance(head.content, str):
|
||||
head.content = head.content + skill_addition
|
||||
elif isinstance(head.content, list):
|
||||
appended = False
|
||||
for ce in head.content:
|
||||
if getattr(ce, 'type', None) == 'text':
|
||||
ce.text = (ce.text or '') + skill_addition
|
||||
appended = True
|
||||
break
|
||||
if not appended:
|
||||
head.content.append(provider_message.ContentElement(type='text', text=skill_addition))
|
||||
else:
|
||||
query.prompt.messages.insert(
|
||||
0,
|
||||
provider_message.Message(role='system', content=skill_addition.strip()),
|
||||
)
|
||||
self.ap.logger.debug(
|
||||
f'Skill index injected into system prompt: '
|
||||
f'pipeline={query.pipeline_uuid} '
|
||||
f'bound_skills={bound_skills or "all"} '
|
||||
f'loaded_skills={len(self.ap.skill_mgr.skills)}'
|
||||
)
|
||||
else:
|
||||
self.ap.logger.debug(
|
||||
f'No skills available for prompt injection: '
|
||||
f'pipeline={query.pipeline_uuid} '
|
||||
f'loaded_skills={len(self.ap.skill_mgr.skills)} '
|
||||
f'bound_skills={bound_skills}'
|
||||
)
|
||||
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
|
||||
@@ -10,6 +10,7 @@ if typing.TYPE_CHECKING:
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
ACTIVATED_SKILLS_KEY = '_activated_skills'
|
||||
ACTIVATED_SKILL_NAMES_STATE_KEY = 'host.activated_skills'
|
||||
PIPELINE_BOUND_SKILLS_KEY = '_pipeline_bound_skills'
|
||||
SKILL_MOUNT_PREFIX = '/workspace/.skills'
|
||||
_SKILL_MOUNT_PATTERN = re.compile(r'/workspace/\.skills/([A-Za-z0-9_-]+)')
|
||||
@@ -72,6 +73,116 @@ def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> N
|
||||
activated[skill_name] = skill_data
|
||||
|
||||
|
||||
def _normalize_skill_names(value: typing.Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
names: list[str] = []
|
||||
for item in value:
|
||||
skill_name = str(item or '').strip()
|
||||
if skill_name and skill_name not in names:
|
||||
names.append(skill_name)
|
||||
return names
|
||||
|
||||
|
||||
def restore_activated_skills_from_state(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
state: dict[str, dict[str, typing.Any]],
|
||||
) -> list[str]:
|
||||
"""Restore persisted activated skill names into Query variables.
|
||||
|
||||
The state value stores names only. Full skill metadata is rebuilt from the
|
||||
current pipeline-visible skill cache so removed or unbound skills remain
|
||||
unavailable to native exec/write/edit.
|
||||
"""
|
||||
conversation_state = state.get('conversation', {}) if isinstance(state, dict) else {}
|
||||
skill_names = _normalize_skill_names(conversation_state.get(ACTIVATED_SKILL_NAMES_STATE_KEY))
|
||||
restored: list[str] = []
|
||||
for skill_name in skill_names:
|
||||
skill_data = get_visible_skill(ap, query, skill_name)
|
||||
if skill_data is None:
|
||||
continue
|
||||
register_activated_skill(query, skill_data)
|
||||
restored.append(skill_name)
|
||||
return restored
|
||||
|
||||
|
||||
def _get_agent_run_authorization(query: pipeline_query.Query) -> dict[str, typing.Any] | None:
|
||||
session = getattr(query, '_agent_run_session', None)
|
||||
if not isinstance(session, dict):
|
||||
return None
|
||||
authorization = session.get('authorization')
|
||||
return authorization if isinstance(authorization, dict) else None
|
||||
|
||||
|
||||
def _get_conversation_state_target(query: pipeline_query.Query) -> tuple[str, str, str, dict[str, typing.Any]] | None:
|
||||
session = getattr(query, '_agent_run_session', None)
|
||||
if not isinstance(session, dict):
|
||||
return None
|
||||
|
||||
authorization = _get_agent_run_authorization(query)
|
||||
if authorization is None:
|
||||
return None
|
||||
|
||||
state_policy = authorization.get('state_policy') or {}
|
||||
if not state_policy.get('enable_state', True):
|
||||
return None
|
||||
state_scopes = state_policy.get('state_scopes', ['conversation', 'actor'])
|
||||
if 'conversation' not in state_scopes:
|
||||
return None
|
||||
|
||||
state_context = authorization.get('state_context') or {}
|
||||
scope_keys = state_context.get('scope_keys') or {}
|
||||
scope_key = scope_keys.get('conversation')
|
||||
if not scope_key:
|
||||
return None
|
||||
|
||||
runner_id = str(session.get('runner_id') or 'unknown')
|
||||
binding_identity = str(state_context.get('binding_identity') or 'unknown')
|
||||
return scope_key, runner_id, binding_identity, state_context
|
||||
|
||||
|
||||
async def persist_activated_skill(ap: app.Application, query: pipeline_query.Query, skill_name: str) -> bool:
|
||||
"""Persist activated skill names for the current AgentRunner conversation.
|
||||
|
||||
Returns False when the call is outside an AgentRunner run or state policy
|
||||
does not expose a conversation scope. The in-memory Query activation still
|
||||
remains valid for the current turn.
|
||||
"""
|
||||
target = _get_conversation_state_target(query)
|
||||
if target is None:
|
||||
return False
|
||||
|
||||
persistence_mgr = getattr(ap, 'persistence_mgr', None)
|
||||
if persistence_mgr is None or not hasattr(persistence_mgr, 'get_db_engine'):
|
||||
return False
|
||||
|
||||
from ....agent.runner.persistent_state_store import get_persistent_state_store
|
||||
|
||||
scope_key, runner_id, binding_identity, state_context = target
|
||||
store = get_persistent_state_store(persistence_mgr.get_db_engine())
|
||||
existing_names = _normalize_skill_names(await store.state_get(scope_key, ACTIVATED_SKILL_NAMES_STATE_KEY))
|
||||
if skill_name not in existing_names:
|
||||
existing_names.append(skill_name)
|
||||
|
||||
success, error = await store.state_set(
|
||||
scope_key=scope_key,
|
||||
state_key=ACTIVATED_SKILL_NAMES_STATE_KEY,
|
||||
value=existing_names,
|
||||
runner_id=runner_id,
|
||||
binding_identity=binding_identity,
|
||||
scope='conversation',
|
||||
context=state_context,
|
||||
logger=getattr(ap, 'logger', None),
|
||||
)
|
||||
if not success:
|
||||
logger = getattr(ap, 'logger', None)
|
||||
if logger is not None:
|
||||
logger.warning(f'Failed to persist activated skill "{skill_name}": {error}')
|
||||
return success
|
||||
|
||||
|
||||
def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]:
|
||||
normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace'
|
||||
if normalized_path == SKILL_MOUNT_PREFIX:
|
||||
|
||||
@@ -82,17 +82,17 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
if not skill_name:
|
||||
raise ValueError('skill_name is required')
|
||||
|
||||
skill_mgr = self.ap.skill_mgr
|
||||
skill_data = skill_mgr.get_skill_by_name(skill_name)
|
||||
from . import skill as skill_loader
|
||||
|
||||
skill_data = skill_loader.get_visible_skill(self.ap, query, skill_name)
|
||||
if skill_data is None:
|
||||
visible_skills = getattr(skill_mgr, 'skills', {})
|
||||
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
||||
available_names = ', '.join(sorted(visible_skills.keys())) or 'none'
|
||||
raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}')
|
||||
|
||||
# Register activated skill for sandbox mount path resolution
|
||||
from . import skill as skill_loader
|
||||
|
||||
skill_loader.register_activated_skill(query, skill_data)
|
||||
await skill_loader.persist_activated_skill(self.ap, query, skill_name)
|
||||
|
||||
# Return SKILL.md content as Tool Result (injects into context)
|
||||
instructions = skill_data.get('instructions', '')
|
||||
@@ -191,13 +191,13 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
return resource_tool.LLMTool(
|
||||
name=ACTIVATE_SKILL_TOOL_NAME,
|
||||
human_desc='Activate a skill',
|
||||
description=self._build_activate_tool_description(),
|
||||
description='Activate a pipeline-visible skill by name and return its instructions as a tool result.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'skill_name': {
|
||||
'type': 'string',
|
||||
'description': 'The skill name to activate (no arguments). E.g., "pdf" or "data-analysis"',
|
||||
'description': 'The skill name to activate.',
|
||||
},
|
||||
},
|
||||
'required': ['skill_name'],
|
||||
@@ -245,50 +245,3 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_activate_tool_description(self) -> str:
|
||||
"""Build tool description with embedded available_skills list."""
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return 'Activate a skill. No skills are currently available.'
|
||||
|
||||
skills = getattr(skill_mgr, 'skills', {})
|
||||
if not skills:
|
||||
return 'Activate a skill. No skills are currently available.'
|
||||
|
||||
# Build <available_skills> section
|
||||
available_skills_lines = ['<available_skills>']
|
||||
for skill_name, skill_data in sorted(skills.items()):
|
||||
description = skill_data.get('description', '')
|
||||
available_skills_lines.append('<skill>')
|
||||
available_skills_lines.append(f'<name>{skill_name}</name>')
|
||||
available_skills_lines.append(f'<description>{description}</description>')
|
||||
available_skills_lines.append('</skill>')
|
||||
available_skills_lines.append('</available_skills>')
|
||||
|
||||
available_skills_block = '\n'.join(available_skills_lines)
|
||||
|
||||
return f"""Activate a skill within the main conversation.
|
||||
|
||||
<skills_instructions>
|
||||
When users ask you to perform tasks, check if any of the available skills
|
||||
below can help complete the task more effectively. Skills provide specialized
|
||||
capabilities and domain knowledge.
|
||||
|
||||
How to use skills:
|
||||
- Invoke skills using this tool with the skill name only (no arguments)
|
||||
- When you invoke a skill, you will see <command-message>
|
||||
The skill is activated
|
||||
</command-message>
|
||||
- The skill's instructions will be provided in the tool result
|
||||
- Examples:
|
||||
- skill_name: "pdf" - invoke the pdf skill
|
||||
- skill_name: "data-analysis" - invoke the data-analysis skill
|
||||
|
||||
Important:
|
||||
- Only use skills listed in <available_skills> below
|
||||
- Do not invoke a skill that is already running
|
||||
- To create a new skill: prepare it in /workspace, then use register_skill tool
|
||||
</skills_instructions>
|
||||
|
||||
{available_skills_block}"""
|
||||
|
||||
@@ -86,50 +86,3 @@ class SkillManager:
|
||||
def get_skill_by_name(self, name: str) -> dict | None:
|
||||
"""Get skill data by name."""
|
||||
return self.skills.get(name)
|
||||
|
||||
def get_skill_index(self, bound_skills: list[str] | None = None) -> str:
|
||||
"""Render the pipeline-visible skills as a short ``name: description``
|
||||
index suitable for the system prompt.
|
||||
|
||||
``bound_skills`` follows the same convention as
|
||||
``query.variables['_pipeline_bound_skills']``: ``None`` means every
|
||||
loaded skill is exposed; an explicit list filters to that subset.
|
||||
Returns an empty string when no skills are visible.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for skill in self.skills.values():
|
||||
name = skill.get('name')
|
||||
if not name:
|
||||
continue
|
||||
if bound_skills is not None and name not in bound_skills:
|
||||
continue
|
||||
display = skill.get('display_name') or name
|
||||
description = (skill.get('description') or '').strip().replace('\n', ' ')
|
||||
lines.append(f'- {name} ({display}): {description}')
|
||||
|
||||
if not lines:
|
||||
return ''
|
||||
return 'Available Skills:\n' + '\n'.join(lines)
|
||||
|
||||
def build_skill_aware_prompt_addition(self, bound_skills: list[str] | None = None) -> str:
|
||||
"""Build the system-prompt addendum that makes the LLM aware of the
|
||||
pipeline-visible skills.
|
||||
|
||||
Only metadata (name + description) is injected — the full SKILL.md is
|
||||
loaded later via the ``activate`` Tool Call, protecting KV cache and
|
||||
matching Claude Code's progressive disclosure pattern. Returns an
|
||||
empty string when no skills are visible (no prompt change at all).
|
||||
"""
|
||||
skill_index = self.get_skill_index(bound_skills)
|
||||
if not skill_index:
|
||||
return ''
|
||||
return (
|
||||
'\n\n'
|
||||
f'{skill_index}\n\n'
|
||||
"When the user's request clearly matches one or more skills "
|
||||
'based on their descriptions above, call the `activate` tool with '
|
||||
'the skill name to load its full instructions. Only the name and '
|
||||
'description are visible here; the actual instructions arrive as '
|
||||
'the tool result. If no skill is a clear match, respond normally '
|
||||
'without activating any skill.'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user