fix(skills): keep a slash skill activation when the model call is retried (#6506)

* fix(skills): keep a slash skill activation when the model call is retried

SkillActivationMiddleware records the activated slash message in the run
context before it calls the model. LLMErrorHandlingMiddleware sits outside
it and retries a failed (429/503/timeout) or empty call by running this wrap
again on the same request; the retry found the run key already set and went
out without the SKILL.md body, while the slash source it had published kept
the skill's tool restrictions in force.

Record the activation itself next to the run key and replay it while nothing
has answered the slash message yet: a retry sees no AI message after the
target, a tool-loop follow-up call does. The replay carries the same reminder
and usage stamp without re-reading the skill or repeating the audit event and
usage record, which now sit with the other once-per-activation side effects.

Two existing tests sent the same unanswered request twice to model a
follow-up call; that is the retry shape, so their second call now includes
the first response, as the real graph does.

* docs(changelog): reference #6506

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-08 20:44:52 +09:00 committed by GitHub
parent 4bab280250
commit 16b4fcd968
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 217 additions and 51 deletions

View File

@ -804,6 +804,13 @@ This release closes that milestone with **439 merged pull requests**.
### Fixed
- **skills:** A `/skill-name` activation now survives a retried model call. The
activation was marked as done before the model was called, so when the call
failed (rate limit, overload, timeout) or came back empty and was retried, the
retry went out without the `SKILL.md` body while the skill's tool restrictions
still applied. The retry now carries the same reminder as the first attempt,
without re-reading the skill or recording a second activation, and the retried
response keeps the skill-usage record. ([#6506])
- **skills:** Skill changes made through one Gateway replica now reach the
skill list in every other replica's system prompt. `SkillStorage` rescans
disk on every call, but the prompt layer caches the enabled-skills list and
@ -9221,3 +9228,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6450]: https://github.com/bytedance/deer-flow/pull/6450
[#6481]: https://github.com/bytedance/deer-flow/pull/6481
[#6495]: https://github.com/bytedance/deer-flow/pull/6495
[#6506]: https://github.com/bytedance/deer-flow/pull/6506

View File

@ -694,6 +694,9 @@
### 修复
- **技能:** `/技能名` 激活现在在模型调用重试时不再丢失。此前激活在调用模型之前就被标记为已完成,因此调用失败
(限流、过载、超时)或返回空响应而重试时,重试请求不再包含 `SKILL.md` 正文,而该技能的工具限制仍然生效。现在重试
会携带与首次尝试相同的激活内容,不会重新读取技能或重复记录激活,重试得到的响应也保留技能使用记录。([#6506])
- **持久化:** `scripts/migrate_user_isolation.py` 现在会把每个 legacy 线程移动到其所属用户下。此前脚本在
`{base_dir}/deer-flow.db` 中查找线程归属,而 DeerFlow 从不创建这个文件(数据库是 `{sqlite_dir}/deerflow.db`
或 PostgreSQL),因此归属映射始终为空,所有 legacy 线程都被移到 `users/default/`。现在归属从 `config.yaml`
@ -7528,3 +7531,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6448]: https://github.com/bytedance/deer-flow/pull/6448
[#6450]: https://github.com/bytedance/deer-flow/pull/6450
[#6481]: https://github.com/bytedance/deer-flow/pull/6481
[#6506]: https://github.com/bytedance/deer-flow/pull/6506

View File

@ -103,7 +103,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../
**Lead-only middlewares** (`build_middlewares`, appended after the base):
17. **DynamicContextMiddleware** - Injects date and optional memory outside the static prompt. Opt-out removes its frozen server memory but retains date/user messages. Date follows server-local time unless `DEER_FLOW_DATE_TIMEZONE` names an IANA zone (invalid values fall back locally).
18. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, injects the `SKILL.md` body as hidden current-turn context, and records a `middleware:skill_activation` audit event
18. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, injects the `SKILL.md` body as hidden current-turn context, and records a `middleware:skill_activation` audit event; a retried activation call replays the recorded reminder without re-auditing
19. **SkillToolPolicyMiddleware** - Applies `allowed-tools` only after real activation; passive enabled skills and a custom agent's configured skill allowlist do not clamp the lead toolset. A run-scoped slash activation is authoritative and suppresses `skill_context` as a policy source, so reading another skill cannot widen the explicit skill's tools; without slash activation, skills captured after configured `read_file` loads retain the existing union semantics. The middleware filters model-visible schemas and blocks unauthorized execution, resolving canonical paths against the live enabled/agent-allowed registry on every model call, then stores a versioned, JSON-safe, middleware-token-bound decision signed by policy source plus active paths in run context for the resulting tool calls to reuse. The next model call always refreshes it, and malformed, foreign, stale, or unmatched decisions fall back to live resolution. `tool_search` and `describe_skill` remain framework-safe discovery tools under a restrictive policy; they may reveal or promote metadata, but a deferred business tool must still be declared by the active policy before its schema or execution can survive the policy middleware. The decision's owner token is authorization-sensitive, so its reserved context key is owned by `runtime.secret_context` and included in `REDACTED_CONTEXT_KEYS` for observable and persisted context copies. Registry load failures and a non-empty active set with no authorized skill fail closed to framework-safe tools; an individual stale path is skipped only when at least one valid active skill remains. This is best-effort behavioral scoping rather than a hard security boundary: alternate loads such as `bash cat` are not captured, and bounded autonomous `skill_context` can evict old entries. `task` is not framework-exempt, so a restricted skill cannot delegate around its policy. The middleware must remain immediately after `SkillActivationMiddleware` (which publishes the slash source through `runtime.secret_context`'s public path helpers authenticated by a required token shared only within the assembled middleware chain) and immediately before `DurableContextMiddleware`; assembly and compiled-graph tests pin ordering, token sharing, schema filtering, and execution blocking.
20. **DurableContextMiddleware** - Before compaction, captures task dispatches/result summaries in `ThreadState.delegations` and loaded skill name/path/description references (never bodies) in `ThreadState.skill_context`; projects them into each model request. Static authority rules are injected as a `SystemMessage`; untrusted field values (`summary_text`, delegation results, skill descriptions) are injected separately as a hidden `HumanMessage` data block so compressed history, delegated work, and which skills are active stay visible without being stored as `messages` or promoted to system-role instructions. `build_subagent_runtime_middlewares` also attaches this middleware immediately before subagent summarization so a compacted `summary_text` is projected ahead of a preserved assistant/tool tail instead of leaving strict providers with an assistant-first request. See [tool artifacts](TOOL_ARTIFACTS.md) for projection, retry limits and delegation scope.
21. **SummarizationMiddleware** - *(optional)* Compacts near token limits. Fraction trigger/keep profiles belong to the active run model; an explicit summary model only generates. Memory flush/manual compaction follow runtime/checkpoint agent policy. Preserve the latest user request and DynamicContext reminders; a backward cutoff may retain old AI/tool turns or make first-turn compaction a no-op.

View File

@ -64,13 +64,15 @@ _REGISTRY_LOAD_FAILED = object()
# 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
# _SLASH_SKILL_ACTIVATION_RUN_KEY: the slash message already activated in this run
# and the activation it produced (_RecordedActivation), so the skill disk read +
# "activate" audit event + usage record 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. A retry of the activation call
# (nothing has answered the slash message yet) replays the recorded reminder rather
# than dropping it. All three live in secret_context so they are covered by
# REDACTED_CONTEXT_KEYS in one place.
@ -91,6 +93,15 @@ class _Activation:
class _ActivationResolution:
activation: _Activation | None = None
failure_message: str | None = None
# A retry of the call that already activated: re-inject the reminder, but the
# once-per-activation side effects (audit, usage record) already happened.
replayed: bool = False
@dataclass(frozen=True, slots=True)
class _RecordedActivation:
run_key: str
activation: _Activation
def is_slash_skill_activation_reminder(message: object) -> bool:
@ -392,8 +403,8 @@ Follow this skill before choosing a general workflow. Load supporting resources
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.
def _recorded_activation(run_context: dict | None, run_key: str) -> _Activation | None:
"""The activation ``run_key`` already produced earlier in this run, if any.
Sibling to ``_has_existing_activation_for_target``: that helper catches an
activation reminder still present in the scanned ``messages`` window; this
@ -402,9 +413,24 @@ Follow this skill before choosing a general workflow. Load supporting resources
``_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.
record — this helper only ever looks the key up, never computes it.
"""
return isinstance(run_context, dict) and run_context.get(_SLASH_SKILL_ACTIVATION_RUN_KEY) == run_key
recorded = run_context.get(_SLASH_SKILL_ACTIVATION_RUN_KEY) if isinstance(run_context, dict) else None
if isinstance(recorded, _RecordedActivation) and recorded.run_key == run_key:
return recorded.activation
return None
@staticmethod
def _target_answered(messages: list, target_index: int) -> bool:
"""Whether a model response to the target already reached graph state.
Tells the tool loop's follow-up calls apart from a retry of the activation
call. LLMErrorHandlingMiddleware sits outside this middleware and retries a
failed or empty call by running this wrap again on the same request, so the
retry sees no AI message after the target; a follow-up call sees the
response the activation call produced.
"""
return any(isinstance(message, AIMessage) for message in messages[target_index + 1 :])
def _find_activation_target(self, messages: list, *, run_context: dict | None = None, activation_decisions: dict[str, bool] | None = None) -> tuple[int, HumanMessage, _ActivationResolution, str] | None:
if not messages:
@ -422,13 +448,19 @@ Follow this skill before choosing a general workflow. Load supporting resources
# 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
# durable signal (see _recorded_activation / _SLASH_SKILL_ACTIVATION_RUN_KEY).
# Once the activation call has been answered, skipping here avoids the
# redundant skill disk read, reminder re-injection, and duplicate "activate"
# audit. Before that, this call is a retry of the activation call and must
# carry the same reminder, replayed from the record rather than re-read so
# it matches what was audited. 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
recorded = self._recorded_activation(run_context, run_key)
if recorded is not None:
if self._target_answered(messages, target_index):
return None
return target_index, target, _ActivationResolution(activation=recorded, replayed=True), run_key
content = get_original_user_content_text(target.content, target.additional_kwargs)
names = target.additional_kwargs.get("skill_references")
@ -495,26 +527,28 @@ Follow this skill before choosing a general workflow. Load supporting resources
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,
)
for item in (activation, *activation.additional_activations):
self._record_activation(request, item, 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
if not resolution.replayed:
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,
)
for item in (activation, *activation.additional_activations):
self._record_activation(request, item, hook=hook)
record_skill_usage(getattr(request, "runtime", None), self._usage_snapshot(item))
# 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] = _RecordedActivation(run_key=run_key, activation=activation)
activation_msg = self._make_activation_message(target, "\n\n".join(self._build_activation_reminder(item, include_user_request=index == 0) for index, item in enumerate((activation, *activation.additional_activations))))
messages = list(request.messages)
messages.insert(target_index, activation_msg)
@ -533,9 +567,6 @@ Follow this skill before choosing a general workflow. Load supporting resources
return prepared, None
effective = prepared if prepared is not None else request
self._resolve_secret_bindings(effective, activation, hook=hook, activation_decisions=activation_decisions, entry_registry=entry_registry)
if activation is not None:
for item in (activation, *activation.additional_activations):
record_skill_usage(getattr(request, "runtime", None), self._usage_snapshot(item))
return effective, activation
@staticmethod

View File

@ -179,8 +179,10 @@ _SECRETS_BINDING_AUDIT_KEY = "__skill_secrets_binding_audit"
# The reminder is injected into the per-call model request only and never written
# back to graph state, so a scan of ``request.messages`` cannot detect a prior
# activation on the 2nd..Nth model call — the run context is the only signal that
# survives (mirroring ``_SLASH_SECRET_SOURCE_KEY``). Holds a message id / content
# digest, never a secret value; listed below to keep the redaction guard complete.
# survives (mirroring ``_SLASH_SECRET_SOURCE_KEY``). Holds the message id / content
# digest plus the activation it produced (skill content, never a secret value), so
# a retry of the activation call can replay the same reminder; listed below to
# keep the redaction guard complete.
_SLASH_SKILL_ACTIVATION_RUN_KEY = "__slash_skill_activation_run"
# Run-context keys whose values are request-scoped secrets and must be stripped

View File

@ -66,10 +66,10 @@ def test_inline_batch_activates_once_with_owner_scoped_secrets_and_usage(catalog
async def ahandler(value):
return handler(value)
def invoke():
return asyncio.run(middleware.awrap_model_call(original, ahandler)) if async_call else middleware.wrap_model_call(original, handler)
def invoke(model_request):
return asyncio.run(middleware.awrap_model_call(model_request, ahandler)) if async_call else middleware.wrap_model_call(model_request, handler)
response = invoke()
response = invoke(original)
reminder = seen[0].messages[0]
assert isinstance(reminder, HumanMessage)
assert reminder.additional_kwargs["hide_from_ui"] is True
@ -86,8 +86,10 @@ def test_inline_batch_activates_once_with_owner_scoped_secrets_and_usage(catalog
assert [entry["name"] for entry in usages] == names
assert journal.record_skill_usage.call_count == count
assert context[_SLASH_SKILL_ACTIVATION_RUN_KEY]
invoke()
assert seen[1].messages == original.messages
# The tool loop's next call sees the answered slash message in state.
follow_up = original.override(messages=[*original.messages, response])
invoke(follow_up)
assert seen[1].messages == follow_up.messages
assert journal.record_skill_usage.call_count == count
assert context[ACTIVE_SECRETS_CONTEXT_KEY] == {f"KEY_{i}": f"value-{i}" for i in range(count)}

View File

@ -4,8 +4,13 @@ from pathlib import Path
from types import SimpleNamespace
import pytest
from langchain.agents import create_agent
from langchain.agents.middleware.types import ModelRequest
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.runnables import Runnable
from langchain_core.tools import tool as as_tool
from pydantic import Field, PrivateAttr
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
from deerflow.agents.middlewares import skill_activation_middleware as middleware_module
@ -394,6 +399,118 @@ def test_skill_activation_middleware_activates_once_across_tool_loop(monkeypatch
assert sum(1 for kwargs in recorded if kwargs.get("action") == "activate") == 1
class _ProviderUnavailable(Exception):
def __init__(self) -> None:
super().__init__("503 Service Unavailable")
self.status_code = 503
self.response = SimpleNamespace(status_code=503, headers={})
class _ScriptedChatModel(FakeMessagesListChatModel):
"""Fake chat model that records every request and raises scripted errors in order."""
failures: list[Exception] = Field(default_factory=list)
_seen_messages: list[list] = PrivateAttr(default_factory=list)
@property
def seen_messages(self) -> list[list]:
return self._seen_messages
def bind_tools(self, tools, *, tool_choice=None, **kwargs) -> Runnable:
return self
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
self._seen_messages.append(list(messages))
if self.failures:
raise self.failures.pop(0)
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
def _run_retrying_slash_graph(monkeypatch, tmp_path, *, model, asynchronous=False):
from deerflow.agents.middlewares.llm_error_handling_middleware import LLMErrorHandlingMiddleware
from deerflow.config.app_config import AppConfig, LlmCallConfig
from deerflow.config.sandbox_config import SandboxConfig
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
disk_reads = {"n": 0}
real_read = SkillActivationMiddleware._read_skill_content
def counting_read(skill_file, skills_root, *, storage=None):
disk_reads["n"] += 1
return real_read(skill_file, skills_root, storage=storage)
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
monkeypatch.setattr(SkillActivationMiddleware, "_read_skill_content", staticmethod(counting_read))
@as_tool
def echo(text: str) -> str:
"""Echo the text back."""
return text
audits = []
usages = []
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: audits.append(kwargs), record_skill_usage=usages.append)
app_config = AppConfig(
sandbox=SandboxConfig(use="test"),
llm_call=LlmCallConfig(retry_max_attempts=3, retry_base_delay_ms=0, retry_cap_delay_ms=0),
)
# Production order: LLMErrorHandling sits outside SkillActivation, so a retry
# runs SkillActivation's wrap again on the same request.
graph = create_agent(
model=model,
tools=[echo],
middleware=[LLMErrorHandlingMiddleware(app_config=app_config), SkillActivationMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)],
)
graph_input = {"messages": [HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")]}
context = {"__run_journal": journal}
config = {"recursion_limit": 20}
result = asyncio.run(graph.ainvoke(graph_input, context=context, config=config)) if asynchronous else graph.invoke(graph_input, context=context, config=config)
carries_skill = [any(is_slash_skill_activation_reminder(message) and "Use pandas." in message.content for message in messages) for messages in model.seen_messages]
return SimpleNamespace(result=result, carries_skill=carries_skill, disk_reads=disk_reads["n"], audits=audits, usages=usages)
@pytest.mark.parametrize("asynchronous", [False, True])
def test_skill_activation_survives_a_retried_model_call_in_real_agent_graph(monkeypatch, tmp_path, asynchronous):
# The run key is recorded before the model call. When the activation call
# raises, LLMErrorHandling retries by running this wrap again on the same
# request; the retry must still carry SKILL.md, replayed from the recorded
# activation without a second disk read, audit event, or usage record.
model = _ScriptedChatModel(
responses=[
AIMessage(content="", tool_calls=[{"name": "echo", "args": {"text": "hi"}, "id": "call-1"}]),
AIMessage(content="final answer"),
],
failures=[_ProviderUnavailable()],
)
run = _run_retrying_slash_graph(monkeypatch, tmp_path, model=model, asynchronous=asynchronous)
# Failed activation call, its retry, then the tool loop's follow-up call.
assert run.carries_skill == [True, True, False]
assert run.result["messages"][-1].content == "final answer"
assert run.disk_reads == 1
assert sum(1 for kwargs in run.audits if kwargs.get("action") == "activate") == 1
assert [usage["name"] for usage in run.usages] == ["data-analysis"]
# The failed attempt's stamped response was discarded; the retry's response
# is the one that reaches state and must carry the usage evidence.
assert run.result["messages"][1].additional_kwargs["skill_usage"]["name"] == "data-analysis"
def test_skill_activation_survives_an_empty_response_retry_in_real_agent_graph(monkeypatch, tmp_path):
# An empty response is not an exception inside this wrap: the handler returns,
# and LLMErrorHandling raises only after it, outside. The retry must still
# carry SKILL.md even though nothing failed from this middleware's view.
model = _ScriptedChatModel(responses=[AIMessage(content=""), AIMessage(content="final answer")])
run = _run_retrying_slash_graph(monkeypatch, tmp_path, model=model)
assert run.carries_skill == [True, True]
assert run.result["messages"][-1].content == "final answer"
assert run.result["messages"][-1].additional_kwargs["skill_usage"]["name"] == "data-analysis"
assert sum(1 for kwargs in run.audits if kwargs.get("action") == "activate") == 1
assert len(run.usages) == 1
def test_skill_activation_middleware_reactivates_on_new_user_slash_command(monkeypatch, tmp_path):
# The run-scoped dedup must not suppress a genuinely new activation: a later
# user slash message (distinct id / text) keys differently, so it still
@ -864,9 +981,10 @@ def test_slash_usage_snapshot_is_persisted_on_first_model_response_only(monkeypa
middleware = SkillActivationMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
recorded = []
runtime = SimpleNamespace(context={"__run_journal": SimpleNamespace(record_skill_usage=recorded.append)})
request = _make_model_request([HumanMessage(content="/data-analysis analyze", id="user-1")], runtime=runtime)
user = HumanMessage(content="/data-analysis analyze", id="user-1")
def call():
def call(messages):
request = _make_model_request(messages, runtime=runtime)
response = ModelResponse(result=[AIMessage(content="Analyzing")])
if asynchronous:
@ -876,7 +994,7 @@ def test_slash_usage_snapshot_is_persisted_on_first_model_response_only(monkeypa
return asyncio.run(middleware.awrap_model_call(request, handler))
return middleware.wrap_model_call(request, lambda _: response)
first = call()
first = call([user])
snapshot = first.result[0].additional_kwargs["skill_usage"]
assert recorded == [snapshot]
assert snapshot["activation"] == "slash"
@ -886,4 +1004,5 @@ def test_slash_usage_snapshot_is_persisted_on_first_model_response_only(monkeypa
assert snapshot["path"] == "/mnt/skills/custom/data-analysis/SKILL.md"
skill.skill_file.write_text("Changed later", encoding="utf-8")
assert snapshot["content"] == content
assert "skill_usage" not in call().result[0].additional_kwargs
# The tool loop's next call sees the answered slash message in state.
assert "skill_usage" not in call([user, *first.result]).result[0].additional_kwargs