deer-flow/backend/packages/harness/deerflow/agents/middlewares/skill_activation_middleware.py
Nan Gao 13f0a7f263
feat(extensions): let an out-of-tree extension observe what the agent did (#4863)
* feat(extensions): let an out-of-tree extension observe what the agent did

DeerFlow's extension system can contribute middleware, services and routes,
but an extension cannot answer basic questions about a run without reaching
into host internals. Several of the facts it would need are destroyed by the
operations that produce them:

  * The middleware chain injects and rewrites a lot of context — date
    reminders, recalled memory, compaction summaries, durable-context data,
    image payloads, activated skill bodies. Downstream, none of it is
    attributable: at the model-call boundary an injected HumanMessage is
    indistinguishable from the user's own, and anything wanting to tell them
    apart has to pattern-match prompt wording, which breaks on the next copy
    edit.

  * Two runs of "the same agent" are only comparable if the chain enforced the
    same limits, prompts and thresholds. Recovering that from outside means
    reading private attributes and guessing which of them change behaviour — a
    guess that rots silently as middlewares gain fields.

  * The lead-agent factory resolves a model after runtime overrides, renders a
    prompt, filters tools through authorization and composes a stack, all
    inside one synchronous call, and none of it survives: a middleware sees its
    neighbours but not the prompt, the run worker sees a graph but not what
    went into it.

  * Summarization is destructive by design. N messages leave the context and
    one summary enters it; afterwards only the summary exists, so "which
    messages became this?" is not reconstructible.

This adds seven neutral facilities so those facts are recorded where they are
still true, and releases the contract package as 0.2.0.

Message provenance
  Producers stamp `deerflow_content_kind` / `deerflow_producer_kind` onto the
  messages they inject or rewrite. Stamping is unconditional — a fact whose
  presence depends on whether an observer is installed is not a fact — and the
  keys are server-owned, so provenance cannot be forged from a request.

Middleware self-description
  Twelve middlewares declare their own behaviour-affecting parameters through
  a duck-typed `release_policy_parameters()`. Long text is hashed rather than
  embedded: a declaration is an identity, not a copy of the prompt.

Agent assembly descriptor
  `assemble_lead_agent()` returns the graph plus a descriptor whose fingerprint
  answers "did anything about this agent change between these two runs?".
  `make_lead_agent()` keeps its graph-only signature — it is the LangGraph
  Server ABI declared in langgraph.json. Tools and skills are sorted before
  hashing because their assembly order is incidental; middlewares are not,
  because stack order decides what wraps what. Host build identity is reported
  but excluded from the fingerprint, so a redeploy does not invalidate every
  agent's identity.

Context compaction observation
  Summarization emits the content hashes of the messages it is about to remove
  joined to the summary that replaced them. Content is the only identity
  available at that seam: the summary does not become a message, and what later
  projects it into a request renders it bounded and escaped rather than
  verbatim.

Neutral policy, transform and MCP-source facts
  Guardrail decisions are published to runtime context under a `__`-prefixed
  key; result-rewriting middlewares append a declared, ordered transform trail;
  MCP tools carry their credential-free logical origin.

Extension route identity
  Contributed routes are session-authenticated and cannot opt out, but
  "logged in" and "administrator" are different questions. Extensions get a
  neutral projection of the caller rather than the host's auth context, and
  `require_admin` fails closed when identity cannot be determined.

Extension-owned tables
  An extension that persists data owns its own MetaData and migration chain, so
  its tables are absent from Base.metadata and `alembic revision --autogenerate`
  proposes dropping them. Extensions declare a table prefix, which is rejected
  at registration if it would shadow a host table.

The contract package stays dependency-free and imports no host code; every new
Protocol method has a default so later additions remain additive. The loader's
pre-1.0 rule requires an exact major.minor match, so extensions written against
0.1 are now refused at startup with an actionable install hint rather than
loading into a host that implements a different surface.

uv.lock records the contract package's new version, so `uv sync --locked` still
resolves on a fresh checkout.

* fix(backend): sort gateway service imports
2026-08-23 09:57:12 +08:00

594 lines
28 KiB
Python

"""Middleware for skill activation: explicit slash + in-context secret binding."""
from __future__ import annotations
import asyncio
import hashlib
import html
import logging
import posixpath
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, override
from deerflow_extension_api import ContentKind, provenance_kwargs
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.messages import AIMessage, HumanMessage
from deerflow.runtime.events.catalog import (
MIDDLEWARE_SKILL_ACTIVATION_TAG,
MIDDLEWARE_SKILL_SECRETS_TAG,
)
from deerflow.runtime.secret_context import (
_SECRETS_BINDING_AUDIT_KEY,
_SLASH_SKILL_ACTIVATION_RUN_KEY,
ACTIVE_SECRETS_CONTEXT_KEY,
extract_request_secrets,
read_slash_skill_source_path,
write_slash_skill_source_path,
)
from deerflow.skills.slash import parse_slash_skill_reference, resolve_slash_skill
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
from deerflow.skills.storage.skill_storage import SkillStorage
from deerflow.skills.types import SKILL_MD_FILE, SecretRequirement, Skill, SkillCategory
from deerflow.utils.messages import get_original_user_content_text, is_real_user_message
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
logger = logging.getLogger(__name__)
_SLASH_SKILL_ACTIVATION_KEY = "slash_skill_activation"
_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY = "slash_skill_activation_target_id"
# _SECRETS_BINDING_AUDIT_KEY: last audited binding (skill and secret names only,
# never values) so unchanged bindings are not re-recorded each call.
# The shared slash-source context contract holds the latest slash activation,
# ONLY the activated skill's canonical container path (never its declared
# secrets — those are read from the live registry on each call, #3938). The
# injection set is recomputed every model call, but a slash-activated skill must
# stay bound for the rest of the run — the model's tool loop issues many model
# calls after the single activation call (#3861 semantics).
# _SLASH_SKILL_ACTIVATION_RUN_KEY: identity of the slash message already activated
# in this run, so the reminder injection + skill disk read + "activate" audit event
# fire once per user slash command instead of on every model call. The reminder is
# added via request.override(messages=...) for a single model call and never
# persisted to graph state, so the 2nd..Nth model call of a turn rebuilds
# request.messages from state without it — the run context is the only signal that
# survives the tool loop. All three live in secret_context so they are covered by
# REDACTED_CONTEXT_KEYS in one place.
@dataclass(frozen=True, slots=True)
class _Activation:
skill_name: str
category: str
container_file_path: str
skill_content: str
content_hash: str
remaining_text: str
editable: bool
required_secrets: tuple[SecretRequirement, ...] = ()
@dataclass(frozen=True, slots=True)
class _ActivationResolution:
activation: _Activation | None = None
failure_message: str | None = None
def is_slash_skill_activation_reminder(message: object) -> bool:
"""Return whether a message is hidden slash-skill activation context."""
return isinstance(message, HumanMessage) and bool(message.additional_kwargs.get(_SLASH_SKILL_ACTIVATION_KEY))
def _is_user_activation_target(message: object) -> bool:
return is_real_user_message(message)
class SkillActivationMiddleware(AgentMiddleware):
"""Inject full SKILL.md content when the user explicitly types /skill-name."""
def __init__(
self,
*,
available_skills: set[str] | None = None,
app_config: AppConfig | None = None,
user_id: str | None = None,
slash_source_owner_token: str,
) -> None:
super().__init__()
if not isinstance(slash_source_owner_token, str) or not slash_source_owner_token:
raise ValueError("slash_source_owner_token must be a non-empty string")
self._available_skills = set(available_skills) if available_skills is not None else None
self._app_config = app_config
self._user_id = user_id
self._slash_source_owner_token = slash_source_owner_token
def release_policy_parameters(self) -> dict[str, object]:
return {
# None means "any enabled, runtime-allowed skill may be activated";
# a concrete list narrows that to a fixed set.
"available_skills": sorted(self._available_skills) if self._available_skills is not None else None,
}
def _storage(self) -> SkillStorage:
if self._user_id is not None:
return get_or_new_user_skill_storage(self._user_id, app_config=self._app_config)
if self._app_config is not None:
return get_or_new_skill_storage(app_config=self._app_config)
return get_or_new_skill_storage()
@staticmethod
def _read_skill_content(skill_file: Path, skills_root: Path, *, storage: SkillStorage | None = None) -> str:
if skill_file.name != SKILL_MD_FILE:
raise ValueError(f"Expected {SKILL_MD_FILE}, got {skill_file.name}")
# Use the storage's path validation if available — UserScopedSkillStorage
# stores custom skills in a per-user directory that is not a sub-path of
# the global skills root, so the simple relative_to check would reject them.
# Fall back to the relative_to check when the storage is a mock (e.g. tests)
# that doesn't implement validate_skill_file_path.
if storage is not None and hasattr(storage, "validate_skill_file_path"):
resolved_file = storage.validate_skill_file_path(skill_file)
else:
resolved_file = skill_file.resolve()
resolved_root = skills_root.resolve()
try:
resolved_file.relative_to(resolved_root)
except ValueError as exc:
raise ValueError("Resolved skill file must stay within the configured skills root.") from exc
if not resolved_file.is_file():
raise FileNotFoundError(resolved_file)
return resolved_file.read_text(encoding="utf-8")
def _resolve_activation(self, text: str) -> _ActivationResolution | None:
reference = parse_slash_skill_reference(text)
if reference is None:
return None
storage = self._storage()
skills = storage.load_skills(enabled_only=False)
skill = next((candidate for candidate in skills if candidate.name == reference.name), None)
if skill is None:
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is not installed.")
if not skill.enabled:
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is installed but disabled. Enable it before using slash activation.")
if self._available_skills is not None and reference.name not in self._available_skills:
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is not available for this agent.")
resolved = resolve_slash_skill(
text,
skills,
available_skills=self._available_skills,
container_base_path=storage.get_container_root(),
)
if resolved is None:
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` could not be resolved.")
try:
skill_content = self._read_skill_content(resolved.skill.skill_file, storage.get_skills_root_path(), storage=storage)
except (OSError, ValueError):
logger.exception("Failed to read slash-activated skill %s", resolved.skill.name)
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` could not be loaded safely. Please check the skill installation.")
content_hash = hashlib.sha256(skill_content.encode("utf-8")).hexdigest()
# CUSTOM skills are editable; PUBLIC and LEGACY are read-only
editable = resolved.skill.category == SkillCategory.CUSTOM
return _ActivationResolution(
activation=_Activation(
skill_name=resolved.skill.name,
category=str(resolved.skill.category),
container_file_path=resolved.container_file_path,
skill_content=skill_content,
content_hash=content_hash,
remaining_text=resolved.remaining_text,
editable=editable,
required_secrets=tuple(resolved.skill.required_secrets or ()),
)
)
@staticmethod
def _build_activation_reminder(activation: _Activation) -> str:
user_request = activation.remaining_text or ("No additional task text was provided after the slash skill command. Ask the user what they want to do with this skill if the next step is unclear.")
escaped_user_request = html.escape(user_request, quote=False)
escaped_skill_content = html.escape(activation.skill_content, quote=False)
escaped_skill_name = html.escape(activation.skill_name, quote=True)
escaped_category = html.escape(activation.category, quote=True)
escaped_path = html.escape(activation.container_file_path, quote=True)
escaped_content_hash = html.escape(activation.content_hash, quote=True)
editable_str = "true" if activation.editable else "false"
return f"""<slash_skill_activation>
The user explicitly activated the `{escaped_skill_name}` skill for this turn.
Treat the task text as:
<user_request>
{escaped_user_request}
</user_request>
Follow this skill before choosing a general workflow. Load supporting resources from the same skill directory only when needed.
<skill name="{escaped_skill_name}" category="{escaped_category}" path="{escaped_path}" sha256="{escaped_content_hash}" editable="{editable_str}">
<skill_content encoding="xml-escaped">
{escaped_skill_content}
</skill_content>
</skill>
</slash_skill_activation>"""
@staticmethod
def _has_existing_activation_for_target(messages: list, target_index: int, target: HumanMessage) -> bool:
if target_index <= 0:
return False
if target.id:
for previous in messages[:target_index]:
if not is_slash_skill_activation_reminder(previous):
continue
target_id = previous.additional_kwargs.get(_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY)
if target_id == target.id or previous.id == f"{target.id}__slash_activation":
return True
previous = messages[target_index - 1]
return is_slash_skill_activation_reminder(previous)
@staticmethod
def _activation_run_key(target: HumanMessage) -> str:
"""Stable identity for a user slash message, used to activate once per run.
Prefers the message id (LangGraph assigns and preserves a stable id once a
message is in graph state); falls back to a digest of the genuine user text
so an id-less message still dedupes within a run. A new user slash message
(new id / new text) yields a new key, so it is not suppressed.
"""
if target.id:
return target.id
content = get_original_user_content_text(target.content, target.additional_kwargs)
return "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
@staticmethod
def _run_context(request: ModelRequest) -> dict | None:
runtime = getattr(request, "runtime", None)
context = getattr(runtime, "context", None)
return context if isinstance(context, dict) else None
@staticmethod
def _already_activated(run_context: dict | None, run_key: str) -> bool:
"""Whether ``run_key`` was already recorded as activated earlier in this run.
Sibling to ``_has_existing_activation_for_target``: that helper catches an
activation reminder still present in the scanned ``messages`` window; this
one catches a prior activation recorded on ``run_context`` whose reminder
already fell out of that window (the tool-loop case — see
``_SLASH_SKILL_ACTIVATION_RUN_KEY``). ``run_key`` is computed once by the
caller (``_find_activation_target``) and reused as-is at the write site in
``_prepare_model_request``, so the same key is always used to check and to
record — this helper only ever checks membership, never computes the key.
"""
return isinstance(run_context, dict) and run_context.get(_SLASH_SKILL_ACTIVATION_RUN_KEY) == run_key
def _find_activation_target(self, messages: list, *, run_context: dict | None = None) -> tuple[int, HumanMessage, _ActivationResolution, str] | None:
if not messages:
return None
target_index = next((idx for idx in range(len(messages) - 1, -1, -1) if _is_user_activation_target(messages[idx])), None)
if target_index is None:
return None
target = messages[target_index]
if target is None:
return None
if self._has_existing_activation_for_target(messages, target_index, target):
return None
# This exact slash message may have already activated earlier in the run.
# The message scan above cannot catch it because the reminder lives only in
# a per-call request override, never in state — the run context is the
# durable signal (see _already_activated / _SLASH_SKILL_ACTIVATION_RUN_KEY).
# Skipping here avoids the redundant skill disk read, reminder re-injection,
# and duplicate "activate" audit. run_key is computed once here and threaded
# through to the write site in _prepare_model_request.
run_key = self._activation_run_key(target)
if self._already_activated(run_context, run_key):
return None
content = get_original_user_content_text(target.content, target.additional_kwargs)
resolution = self._resolve_activation(content)
if resolution is None:
return None
return target_index, target, resolution, run_key
@staticmethod
def _record_activation(request: ModelRequest, activation: _Activation, *, hook: str) -> None:
runtime = getattr(request, "runtime", None)
context = getattr(runtime, "context", None)
journal = context.get("__run_journal") if isinstance(context, dict) else None
if journal is None:
return
try:
journal.record_middleware(
MIDDLEWARE_SKILL_ACTIVATION_TAG,
name="SkillActivationMiddleware",
hook=hook,
action="activate",
changes={
"skill_name": activation.skill_name,
"category": activation.category,
"path": activation.container_file_path,
"content_hash": activation.content_hash,
},
)
except Exception:
logger.warning("Failed to record slash skill activation audit event", exc_info=True)
def _prepare_model_request(self, request: ModelRequest, *, hook: str) -> tuple[ModelRequest | AIMessage | None, _Activation | None]:
run_context = self._run_context(request)
target_and_resolution = self._find_activation_target(list(request.messages), run_context=run_context)
if target_and_resolution is None:
return None, None
target_index, target, resolution, run_key = target_and_resolution
if resolution.failure_message:
return AIMessage(content=resolution.failure_message), None
activation = resolution.activation
if activation is None:
return None, None
logger.info(
"SkillActivationMiddleware: activating slash skill %s category=%s path=%s hash=%s",
activation.skill_name,
activation.category,
activation.container_file_path,
activation.content_hash,
)
self._record_activation(request, activation, hook=hook)
# Mark this slash message as activated for the run so the tool loop's later
# model calls skip the redundant re-activation (#3861: one activation call,
# many follow-up model calls). A new user slash message keys differently and
# still activates. Overwrite (`=`), not append/accumulate, is intentional:
# _find_activation_target only ever considers the latest real user message as
# an activation target, so there is nothing earlier in the run worth
# remembering once a new activation replaces it — do not "fix" this into a
# set. run_key is the same value already checked in _find_activation_target
# (computed once there, threaded through here) rather than recomputed.
if run_context is not None:
run_context[_SLASH_SKILL_ACTIVATION_RUN_KEY] = run_key
activation_msg = self._make_activation_message(target, self._build_activation_reminder(activation))
messages = list(request.messages)
messages.insert(target_index, activation_msg)
return request.override(messages=messages), activation
def _handle_model_request(self, request: ModelRequest, *, hook: str) -> ModelRequest | AIMessage:
prepared, activation = self._prepare_model_request(request, hook=hook)
if isinstance(prepared, AIMessage):
return prepared
effective = prepared if prepared is not None else request
self._resolve_secret_bindings(effective, activation, hook=hook)
return effective
def _resolve_secret_bindings(self, request: ModelRequest, activation: _Activation | None, *, hook: str) -> None:
"""Recompute the per-run secret injection set (binding point A+, #3861/#3914).
Sources, unioned on every model call:
- the most recent slash activation of this run (persisted as a source on
the run context so the whole tool loop after the activation call keeps
the binding — a new slash activation replaces it). The slash source is
validated once, at activation (enabled + allowlist checks in
``_resolve_activation``), and deliberately NOT re-validated per call:
slash is a run-scoped commitment made by the user, and it dies with
the run anyway;
- skills the model loaded earlier in the thread (``ThreadState.skill_context``),
re-validated against the live registry on each call: enabled,
runtime-allowed for this agent, and not opted out via
``secrets-autonomous: false``. Slash activation is exempt from the
opt-out — it is the explicit-ceremony path.
The set is recomputed and REPLACED each call, so a skill evicted from
skill_context, or a caller that stops supplying a value, loses its
injection on the next call automatically. Injected values always come
from the caller's request (``context.secrets``) — never the host
environment, which ``env_policy.build_sandbox_env`` scrubs before
injection — so a skill can never harvest a host platform credential.
Secret *values* are never logged; the audit journal records names only.
"""
runtime = getattr(request, "runtime", None)
context = getattr(runtime, "context", None)
if not isinstance(context, dict):
return
# The slash source records the canonical container path plus a
# middleware-chain-local owner token — never declared secrets. Both
# consumers authenticate the source and resolve the live registry skill
# by path, so caller-mergeable context cannot forge an activation.
if activation is not None:
write_slash_skill_source_path(
context,
activation.container_file_path,
owner_token=self._slash_source_owner_token,
)
request_secrets = extract_request_secrets(context)
sources: list[tuple[str, tuple[SecretRequirement, ...]]] = []
if request_secrets:
registry = self._load_skill_registry_by_path()
if registry is not None:
# Slash source: exempt from the ``secrets-autonomous`` opt-out
# (explicit ceremony), but still enabled + allowlist checked.
slash_path = read_slash_skill_source_path(context, owner_token=self._slash_source_owner_token)
slash_skill = self._resolve_registry_skill(registry, slash_path, require_autonomous=False)
if slash_skill is not None:
sources.append((slash_skill.name, tuple(slash_skill.required_secrets)))
sources.extend(self._in_context_secret_sources(request, registry))
injected: dict[str, str] = {}
bound_skills: set[str] = set()
missing: dict[str, list[str]] = {}
for skill_name, requirements in sources:
for req in requirements:
if req.name in request_secrets:
injected[req.name] = request_secrets[req.name]
bound_skills.add(skill_name)
elif not req.optional:
missing.setdefault(skill_name, []).append(req.name)
if injected:
context[ACTIVE_SECRETS_CONTEXT_KEY] = injected
else:
context.pop(ACTIVE_SECRETS_CONTEXT_KEY, None)
audit_state = {
"skills": sorted(bound_skills),
"secrets": sorted(injected),
"missing": {name: sorted(values) for name, values in sorted(missing.items())},
}
previous = context.get(_SECRETS_BINDING_AUDIT_KEY)
if previous == audit_state:
return
if previous is None and not injected and not missing:
return
context[_SECRETS_BINDING_AUDIT_KEY] = audit_state
for skill_name, names in sorted(missing.items()):
logger.warning(
"Skill %s is active but required secrets are missing from the request context: %s",
skill_name,
", ".join(names),
)
self._record_secret_binding(context, audit_state, hook=hook)
def _load_skill_registry_by_path(self) -> dict[str, Skill] | None:
"""Load the live skill registry keyed by normalized container file path.
Reloaded every call on purpose (not cached): load_skills re-reads the
enabled state from extensions_config so an operator disabling a skill
revokes its secret binding on the very next model call. A cache keyed on
file mtimes would miss enable/disable toggles (which do not touch
SKILL.md) and keep injecting after a disable — trading the
immediate-revocation security property for speed. The cost is gated: the
only caller runs this only when the caller supplied secrets.
Paths are normalized so a non-canonical ``container_path`` config (e.g. a
trailing slash) still matches the canonical path captured in
``skill_context`` (#3938). Returns ``None`` if the registry can't load —
both the slash and in-context sources then bind nothing for that call
(fail closed). This is a deliberate availability-for-security trade-off:
a transient registry read failure mid-run drops the injection for that
call rather than trusting stale caller-supplied data.
"""
try:
storage = self._storage()
skills = storage.load_skills(enabled_only=False)
container_root = storage.get_container_root()
except Exception:
logger.exception("Failed to load skills while resolving secret bindings")
return None
return {posixpath.normpath(skill.get_container_file_path(container_root)): skill for skill in skills}
def _resolve_registry_skill(self, registry: dict[str, Skill], path: object, *, require_autonomous: bool) -> Skill | None:
"""Resolve a container path to a live registry skill eligible for secret
binding, or ``None``.
Match strictly by normalized container file path — never by name. A
by-name fallback would be a confused deputy: DeerFlow lets a custom skill
shadow a same-named public/legacy one (load_skills de-dupes by name,
custom wins), so a reference to public/foo could bind the custom foo's
secrets. A path that does not resolve simply binds nothing (the safe
direction), which also fails closed on a caller-forged path (#3938).
Gates: the skill must be enabled, declare secrets, and be allowlisted for
this agent. ``require_autonomous`` additionally enforces the
``secrets-autonomous`` opt-out for the in-context path; the slash path
passes ``False`` because explicit activation is the ceremony that opt-out
is meant to preserve.
"""
if not isinstance(path, str) or not path:
return None
skill = registry.get(posixpath.normpath(path))
if skill is None or not skill.enabled or not skill.required_secrets:
return None
if require_autonomous and not skill.secrets_autonomous:
return None
if self._available_skills is not None and skill.name not in self._available_skills:
return None
return skill
def _in_context_secret_sources(self, request: ModelRequest, registry: dict[str, Skill]) -> list[tuple[str, tuple[SecretRequirement, ...]]]:
"""Map ``ThreadState.skill_context`` entries to declared-secret sources.
Entries are references to skills the model actually loaded in this
thread. Each is re-validated against the live registry so a skill that
was disabled, uninstalled, opted out, or removed from the agent's
allowlist after being read stops binding immediately.
"""
state = getattr(request, "state", None) or {}
try:
entries = state.get("skill_context") or []
except AttributeError:
return []
sources: list[tuple[str, tuple[SecretRequirement, ...]]] = []
seen: set[str] = set()
for entry in entries:
if not isinstance(entry, dict):
continue
skill = self._resolve_registry_skill(registry, entry.get("path"), require_autonomous=True)
if skill is None or skill.name in seen:
continue
seen.add(skill.name)
sources.append((skill.name, tuple(skill.required_secrets)))
return sources
@staticmethod
def _record_secret_binding(context: dict, audit_state: dict, *, hook: str) -> None:
journal = context.get("__run_journal")
if journal is None:
return
try:
journal.record_middleware(
MIDDLEWARE_SKILL_SECRETS_TAG,
name="SkillActivationMiddleware",
hook=hook,
action="bind_secrets",
changes=audit_state,
)
except Exception:
logger.warning("Failed to record skill secret binding audit event", exc_info=True)
@staticmethod
def _make_activation_message(target: HumanMessage, activation_content: str) -> HumanMessage:
stable_id = target.id or str(uuid.uuid4())
additional_kwargs = {
"hide_from_ui": True,
_SLASH_SKILL_ACTIVATION_KEY: True,
**provenance_kwargs(ContentKind.SKILL_BODY, "skill_activation"),
}
if target.id:
additional_kwargs[_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY] = target.id
return HumanMessage(
content=activation_content,
id=f"{stable_id}__slash_activation",
additional_kwargs=additional_kwargs,
)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse | AIMessage:
prepared = self._handle_model_request(request, hook="wrap_model_call")
if isinstance(prepared, AIMessage):
return prepared
return handler(prepared)
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse | AIMessage:
prepared = await asyncio.to_thread(self._handle_model_request, request, hook="awrap_model_call")
if isinstance(prepared, AIMessage):
return prepared
return await handler(prepared)