fix(skill): reconcile rebase with runner contracts

This commit is contained in:
huanghuoguoguo
2026-09-28 10:54:31 +08:00
parent 38a7c10d04
commit c56fa0ba2e
14 changed files with 152 additions and 35 deletions
+6 -5
View File
@@ -150,10 +150,15 @@ class AgentRunOrchestrator:
resources=resources,
)
authorized_skill_names = [
str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name')
]
prepare_execution_query(execution_query, event, authorized_skill_names)
session_query_id = None
if adapter_context:
if execution_query is not None:
skill_loader.restore_activated_skills_from_state(
await skill_loader.restore_activated_skills_from_state(
self.ap,
execution_query,
context.get('state', {}),
@@ -164,10 +169,6 @@ class AgentRunOrchestrator:
if 'params' in adapter_context:
context['adapter']['extra']['params'] = adapter_context['params']
authorized_skill_names = [
str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name')
]
prepare_execution_query(execution_query, event, authorized_skill_names)
context['variables'] = {
key: value
for key, value in (execution_query.variables or {}).items()
@@ -148,6 +148,10 @@ class MaintenanceService:
scoped_storage_path = Path('data/storage') / self.ap.storage_mgr.scoped_prefix(context)
roots = [('storage', scoped_storage_path)]
skill_repository = getattr(self.ap, 'skill_repository', None)
if skill_repository is not None:
roots.append(('skills', Path(skill_repository.storage_root(context))))
sections = await asyncio.to_thread(self._collect_sections, roots)
runtime_processes = await self._runtime_storage_processes(
context,
-3
View File
@@ -152,8 +152,6 @@ class RunnerBoxService:
if set(request.options) - {'image'}:
raise BoxValidationError('Only the Box image can be specified; resource limits are Host-owned')
spec = {'session_id': session_id, **request.options}
if query is not None:
spec['extra_mounts'] = self.box.build_skill_extra_mounts(query)
result = await self.box.create_session(context, spec)
return self.public_session(result)
@@ -174,7 +172,6 @@ class RunnerBoxService:
return self.binding_info(current)
fields = ('image', 'network', 'cpus', 'memory_mb', 'pids_limit', 'read_only_rootfs', 'persistent')
spec = {key: item[key] for key in fields if key in item}
spec['extra_mounts'] = self.box.build_skill_extra_mounts(query)
binding = RunBoxBinding(run_id, box_id, spec, run_id)
object.__setattr__(query, '_box_binding', binding)
return self.binding_info(binding)
@@ -352,7 +352,7 @@ class NativeToolLoader(loader.ToolLoader):
# All exec calls (with or without skills) go through the same container
# via execute_tool. Skills are mounted at /workspace/.skills/{name}/
# via extra_mounts built by BoxService.
# via read-only mounts composed by the Core skill loader.
result = await self.ap.box_service.execute_tool(
parameters,
query,
+24 -13
View File
@@ -73,6 +73,7 @@ def register_created_skill_visibility(query: pipeline_query.Query, skill_name: s
if isinstance(bound_skills, list) and skill_name not in bound_skills:
bound_skills.append(skill_name)
def build_execution_mounts(ap: app.Application, query: pipeline_query.Query) -> list[dict]:
"""Mount only immutable revisions pinned by this run's activations."""
@@ -217,20 +218,30 @@ async def restore_activated_skills(
return restored
def restore_activated_skills_from_state(
async 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.
"""
"""Restore the exact activated Skill revisions stored by the Host."""
conversation_state = state.get('conversation', {}) if isinstance(state, dict) else {}
skill_names = normalize_skill_names(conversation_state.get(ACTIVATED_SKILL_NAMES_STATE_KEY))
return restore_activated_skills(ap, query, skill_names)
if not isinstance(conversation_state, dict):
raise ValueError('Activated Skill conversation state must be an object.')
if ACTIVATED_SKILL_NAMES_STATE_KEY not in conversation_state:
return []
bindings = conversation_state[ACTIVATED_SKILL_NAMES_STATE_KEY]
if isinstance(bindings, list) and bindings and all(isinstance(item, str) for item in bindings):
logger = getattr(ap, 'logger', None)
if logger is not None:
workspace_uuid = getattr(query, 'workspace_uuid', None) or getattr(
getattr(query, '_execution_context', None), 'workspace_uuid', 'unknown'
)
logger.warning(
f'Legacy name-only activated Skill state in Workspace {workspace_uuid}; '
'reactivate Skills to pin their revisions.'
)
return []
return await restore_activated_skills(ap, query, bindings)
async def persist_activated_skill(
@@ -238,10 +249,10 @@ async def persist_activated_skill(
query: pipeline_query.Query,
skill_name: str,
) -> None:
"""Persist activated skill names into host-owned conversation state.
"""Persist activated Skill revision bindings into Host-owned conversation state.
``activate`` runs host-side. This writes the run's current activated skill
names to the conversation-scope ``host.activated_skills`` snapshot so a later
``activate`` runs host-side. This writes the run's pinned Skill revisions
to the conversation-scope ``host.activated_skills`` snapshot so a later
run can restore them via ``restore_activated_skills_from_state``. Host writes
here and a runner ``state.updated`` to the same key follow last-write-wins.
@@ -273,7 +284,7 @@ async def persist_activated_skill(
await store.state_set(
scope_key=conversation_scope_key,
state_key=ACTIVATED_SKILL_NAMES_STATE_KEY,
value=get_activated_skill_names(query),
value=get_activated_skill_bindings(query),
runner_id=str(session.get('runner_id', '') or ''),
binding_identity=str(state_context.get('binding_identity', 'unknown') or 'unknown'),
scope='conversation',
+7 -3
View File
@@ -376,7 +376,8 @@ class ToolManager:
if source in {'builtin', 'native'}:
return await self.native_tool_loader.get_tool(name)
if source == 'skill':
return await self.skill_tool_loader.get_tool(name)
sandbox_available = await self._workspace_sandbox_available(context)
return await self.skill_tool_loader.get_tool(name, sandbox_available=sandbox_available)
if source == 'plugin':
if not source_id:
return None
@@ -512,11 +513,11 @@ class ToolManager:
telemetry_source = 'native'
exists = await loader.has_tool(name)
elif source == 'skill':
if not sandbox_available:
if not sandbox_available and self.skill_tool_loader.is_sandbox_tool(name):
raise ToolNotFoundError(name)
loader = self.skill_tool_loader
telemetry_source = 'skill'
exists = await loader.has_tool(name)
exists = await loader.has_tool(name, sandbox_available=sandbox_available)
elif source == 'plugin' and source_id:
loader = self.plugin_tool_loader
telemetry_source = 'plugin'
@@ -537,6 +538,9 @@ class ToolManager:
if not exists:
raise ToolNotFoundError(name)
if source == 'skill' and isinstance(getattr(query, 'variables', None), dict):
query.variables['_skill_execution_available'] = sandbox_available
async def invoke_selected_tool() -> typing.Any:
if source == 'mcp':
return await loader.invoke_tool(
+3
View File
@@ -103,6 +103,9 @@ class SkillRepository:
def _workspace_root(self, namespace: str) -> str:
return os.path.join(self._default_workspace(), 'tenants', namespace)
def storage_root(self, context: TenantContext) -> str:
return self._store.scoped(self._namespace(context)).root
async def _call(self, context: TenantContext, method_name: str, *args, **kwargs):
execution_context = await self._validated_execution_context(context)
namespace = self._namespace(execution_context)