Daoyuan Li 2fa0505070
fix(skills): activate a slash skill once per run, not per model call (#4103)
* fix(skills): activate a slash skill once per run, not per model call

SkillActivationMiddleware injects the activation reminder for a slash
command via request.override(messages=...), which LangChain's create_agent
uses for a single model call and never writes back to graph state. The
dedup guard scans request.messages for a prior reminder, but model_node
rebuilds request.messages fresh from persisted state on every tool-loop
step, so the reminder is never present on the 2nd..Nth model call of a
turn. Every model call therefore re-parsed the command, re-read SKILL.md
from disk, re-injected the multi-KB body, and re-recorded an "activate"
audit event, despite the code intending a single activation per run
(#3861 semantics: one activation call, many follow-up model calls).

Key the dedup off the run context instead, which LangGraph threads
through every model-node call of a run (the same durable signal the
request-scoped secret source already uses). The activation call records
the slash message's identity in context; later calls for the same message
skip re-activation. A new user slash message keys differently and still
activates. Secret binding is unaffected: it already re-resolves from the
persisted slash source on every call.

Adds regression tests that rebuild the real multi-call turn state and
assert a single activation across the tool loop, plus a test proving a
new slash command still activates.

* fix(skills): address review nits on run-scoped activation dedup

- Extract _already_activated(run_context, run_key) so the dedup check
  mirrors the existing _has_existing_activation_for_target sibling
  instead of an inline dense conditional.
- Compute _activation_run_key() once in _find_activation_target and
  thread it through _prepare_model_request instead of recomputing it
  at the write site, making the "same key for check and write"
  invariant explicit in the code rather than implicit.
- Document why the run-context write is an overwrite rather than an
  append/set: only the latest real user message is ever considered an
  activation target, so there is nothing earlier in the run worth
  preserving.
- Add a regression test locking in the degraded-path contract: when
  runtime.context is None, the middleware still activates per-call
  instead of crashing or wrongly no-op'ing.
2026-07-13 18:38:32 +08:00
..