Merge branch 'main' into MemoryManager

This commit is contained in:
qin-chenghan
2026-07-13 18:55:11 +08:00
committed by GitHub
9 changed files with 454 additions and 40 deletions
@@ -20,6 +20,7 @@ from langchain_core.messages import AIMessage, HumanMessage
from deerflow.runtime.secret_context import (
_SECRETS_BINDING_AUDIT_KEY,
_SLASH_SECRET_SOURCE_KEY,
_SLASH_SKILL_ACTIVATION_RUN_KEY,
ACTIVE_SECRETS_CONTEXT_KEY,
extract_request_secrets,
)
@@ -44,8 +45,15 @@ _SLASH_SKILL_ACTIVATION_TARGET_ID_KEY = "slash_skill_activation_target_id"
# 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). Both live in
# secret_context so they are covered by REDACTED_CONTEXT_KEYS in one place.
# 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)
@@ -207,7 +215,42 @@ Follow this skill before choosing a general workflow. Load supporting resources
previous = messages[target_index - 1]
return is_slash_skill_activation_reminder(previous)
def _find_activation_target(self, messages: list) -> tuple[int, HumanMessage, _ActivationResolution] | None:
@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
@@ -220,12 +263,22 @@ Follow this skill before choosing a general workflow. Load supporting resources
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
return target_index, target, resolution, run_key
@staticmethod
def _record_activation(request: ModelRequest, activation: _Activation, *, hook: str) -> None:
@@ -251,11 +304,12 @@ Follow this skill before choosing a general workflow. Load supporting resources
logger.debug("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]:
target_and_resolution = self._find_activation_target(list(request.messages))
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 = target_and_resolution
target_index, target, resolution, run_key = target_and_resolution
if resolution.failure_message:
return AIMessage(content=resolution.failure_message), None
@@ -271,6 +325,17 @@ Follow this skill before choosing a general workflow. Load supporting resources
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)
@@ -59,6 +59,16 @@ def read_active_secrets(context: Any) -> dict[str, str]:
_SLASH_SECRET_SOURCE_KEY = "__slash_skill_secret_source"
_SECRETS_BINDING_AUDIT_KEY = "__skill_secrets_binding_audit"
# Identity of the latest slash activation that has already fired in this run, so
# the reminder injection, skill disk read, and ``activate`` audit event happen
# once per user slash command rather than on every model call of the tool loop.
# 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.
_SLASH_SKILL_ACTIVATION_RUN_KEY = "__slash_skill_activation_run"
# Run-context keys whose values are request-scoped secrets and must be stripped
# before a context mapping is serialized anywhere observable (traces, logs).
REDACTED_CONTEXT_KEYS = frozenset(
@@ -67,6 +77,7 @@ REDACTED_CONTEXT_KEYS = frozenset(
ACTIVE_SECRETS_CONTEXT_KEY,
_SLASH_SECRET_SOURCE_KEY,
_SECRETS_BINDING_AUDIT_KEY,
_SLASH_SKILL_ACTIVATION_RUN_KEY,
}
)
@@ -15,6 +15,7 @@ from typing import NamedTuple
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.sandbox.env_policy import build_sandbox_env
from deerflow.sandbox.local.list_dir import list_dir
from deerflow.sandbox.path_patterns import build_output_mask_pattern
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches
@@ -220,23 +221,22 @@ class LocalSandbox(Sandbox):
@cached_property
def _reverse_output_patterns(self) -> list[re.Pattern[str]]:
"""Compiled matchers for local paths in command output (longest local path first)."""
# Same segment-boundary lookahead as the forward patterns above, so a mount
# root does not match inside a sibling that merely shares its prefix
# (``.../skills`` inside ``.../skills-extra``). Without it the regex yields
# the bare root, which then *equals* the mount root and so satisfies
# ``_reverse_resolve_path``'s own ``+ "/"`` guard — the sibling is rewritten
# to a container path that forward resolution refuses to map back.
# The rule — segment boundary plus path tail — is owned by
# ``deerflow.sandbox.path_patterns`` and shared with
# ``sandbox.tools._compiled_mask_patterns``, the other site that rewrites host
# paths back to virtual ones. Its rationale (why the boundary class is
# text-oriented rather than shell-oriented like ``_command_pattern``, why ``$``
# is load-bearing) lives with the owner rather than in a second copy here, which
# is what let the two drift before (#4035 added the boundary here and missed
# that site; #4053 added it there).
#
# The boundary class mirrors ``_content_pattern``'s, not ``_command_pattern``'s:
# this runs over arbitrary command output, where a root can legitimately be
# followed by ``,`` ``:`` or ``\`` — all of which the shell-oriented class
# would reject. The trailing group keeps ``[/\\]`` so Windows paths still match.
#
# ``$`` is load-bearing: output ending exactly at a mount root would
# otherwise fail the lookahead and be emitted as the raw host path.
boundary = r"(?=/|$|[^\w./-])"
tail = r"(?:[/\\][^\s\"';&|<>()]*)?"
return [re.compile(re.escape(self._resolved_local_paths[m]) + boundary + tail) for m in self._mappings_by_local_specificity]
# What is specific to this site: without the boundary the regex yields the bare
# root, which then *equals* the mount root and so satisfies
# ``_reverse_resolve_path``'s own ``+ "/"`` guard — the sibling is rewritten to a
# container path that forward resolution refuses to map back. And bases stay
# separator-*sensitive*: they come from ``Path.resolve()`` and already carry the
# platform's separator, so relaxing them would widen what this masks.
return [build_output_mask_pattern(self._resolved_local_paths[m]) for m in self._mappings_by_local_specificity]
@cached_property
def _resolved_local_paths(self) -> dict[PathMapping, str]:
@@ -0,0 +1,68 @@
"""Shared construction of the host→virtual output-masking regexes.
The boundary and tail are deliberately private: ``build_output_mask_pattern`` is
the only supported way to spell this rule, so a third site cannot import the
pieces and hand-roll a variant that drifts from the other two.
Two independent call sites rewrite host paths back to their virtual form in
text that flows to the model: ``LocalSandbox._reverse_output_patterns`` (bash
output) and ``sandbox.tools._compiled_mask_patterns`` (glob/grep/ls results).
They must agree on where a host base is allowed to end, because both feed the
same downstream contract — a match that stops short of a real segment boundary
is rewritten to a container path that forward resolution then refuses to map
back.
Keeping one copy of that rule per file is what let it drift: #4035 added the
segment boundary to the reverse patterns and missed the masking patterns, and
#4053 had to add the same boundary to the other copy. This module holds the
rule once so a third copy cannot silently disagree.
The two sites are *not* identical, and the difference is deliberate — see
``separator_agnostic``.
"""
from __future__ import annotations
import re
# Only match where a host base ends at a real path-segment boundary, so a mount
# root does not match inside a sibling that merely shares its prefix
# (``.../skills`` inside ``.../skills-extra``).
#
# The class is text-oriented, not shell-oriented (contrast
# ``LocalSandbox._command_pattern``): both callers run over arbitrary command
# output or file listings, where a root can legitimately be followed by ``,``
# ``:`` or ``\``, all of which a shell-oriented class would reject.
#
# ``$`` is load-bearing: output ending exactly at a mount root would otherwise
# fail the lookahead and be emitted as the raw host path.
_SEGMENT_BOUNDARY = r"(?=/|$|[^\w./-])"
# The path tail following the base. ``[/\\]`` keeps Windows-separated paths
# matching; the negated class stops at whitespace and shell punctuation so a
# path embedded in a larger line is not over-consumed.
_PATH_TAIL = r"(?:[/\\][^\s\"';&|<>()]*)?"
def build_output_mask_pattern(base: str, *, separator_agnostic: bool = False) -> re.Pattern[str]:
"""Compile the matcher for one host ``base`` in model-visible output.
Args:
base: Host path root to match (already resolved by the caller).
separator_agnostic: Accept either separator *inside* the base, so a
base captured with ``\\`` still matches output that spells the same
path with ``/``. ``sandbox.tools`` needs this because it derives its
bases from ``_path_variants`` (which yields Windows-style spellings)
and matches them against output whose separators it does not
control. ``LocalSandbox`` does not: its bases come from
``Path.resolve()``, so they already carry the running platform's
separator, and relaxing them would widen what it masks.
Returns:
A compiled pattern matching ``base`` at a segment boundary, plus an
optional path tail.
"""
escaped = re.escape(base)
if separator_agnostic:
escaped = escaped.replace(r"\\", r"[/\\]")
return re.compile(escaped + _SEGMENT_BOUNDARY + _PATH_TAIL)
@@ -23,6 +23,7 @@ from deerflow.sandbox.exceptions import (
SandboxRuntimeError,
)
from deerflow.sandbox.file_operation_lock import get_file_operation_lock
from deerflow.sandbox.path_patterns import build_output_mask_pattern
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
from deerflow.sandbox.search import GrepMatch
@@ -727,20 +728,16 @@ def _compiled_mask_patterns(sources: tuple[tuple[str, str], ...]) -> tuple[tuple
glob/grep match, so without this the same patterns are recompiled per
match.
"""
# Same segment-boundary lookahead as ``LocalSandbox._reverse_output_patterns``
# (#4035), so a host base does not match inside a sibling that merely shares
# its prefix (``.../skills`` inside ``.../skills-extra``). Without it the
# regex yields the bare base, which then *equals* ``base`` in
# ``replace_match`` and so the sibling is rewritten to a container path that
# forward resolution refuses to map back.
# The segment boundary and path tail are shared with
# ``LocalSandbox._reverse_output_patterns`` — see
# ``deerflow.sandbox.path_patterns``, which owns that rule so the two copies
# cannot drift again (#4035 fixed one and missed the other; #4053 fixed the
# other).
#
# The class mirrors ``_content_pattern``'s: this runs over arbitrary command
# output, where a base can legitimately be followed by ``,`` ``:`` or ``\``.
# ``$`` is load-bearing — output ending exactly at a base would otherwise
# fail the lookahead and be emitted as the raw host path.
boundary = r"(?=/|$|[^\w./-])"
tail = r"(?:[/\\][^\s\"';&|<>()]*)?"
# ``separator_agnostic=True`` is the one thing this site does differently:
# its bases come from ``_path_variants``, which yields Windows-style
# spellings, and they are matched against output whose separators this layer
# does not control.
compiled: list[tuple[re.Pattern[str], str, str]] = []
for host_base, virtual_base in sources:
seen: set[str] = set()
@@ -753,8 +750,7 @@ def _compiled_mask_patterns(sources: tuple[tuple[str, str], ...]) -> tuple[tuple
if variant in seen:
continue
seen.add(variant)
escaped = re.escape(variant).replace(r"\\", r"[/\\]")
compiled.append((re.compile(escaped + boundary + tail), variant, virtual_base))
compiled.append((build_output_mask_pattern(variant, separator_agnostic=True), variant, virtual_base))
return tuple(compiled)
+11
View File
@@ -1,4 +1,5 @@
import pytest
from langchain_core.tools import StructuredTool
from langchain_core.tools import tool as as_tool
from deerflow.tools.builtins.tool_search import MAX_RESULTS, DeferredToolCatalog
@@ -123,3 +124,13 @@ def test_hash_changes_with_membership():
c1 = DeferredToolCatalog((alpha_search, beta_translate))
c2 = DeferredToolCatalog((alpha_search,))
assert c1.hash != c2.hash
def test_search_select_not_capped_at_max_results():
"""select: must return ALL requested tools, even when >MAX_RESULTS."""
tools = tuple(StructuredTool.from_function(func=lambda q="": q, name=f"tool_{i:02d}", description=f"Tool {i}") for i in range(8))
cat = DeferredToolCatalog(tools)
names_csv = ",".join(f"tool_{i:02d}" for i in range(8))
got = cat.search(f"select:{names_csv}")
assert len(got) == 8
assert [t.name for t in got] == [f"tool_{i:02d}" for i in range(8)]
+129
View File
@@ -0,0 +1,129 @@
"""Tests for the shared host→virtual output-mask pattern (``sandbox/path_patterns.py``).
The rule these pin is not "the regex is correct" — that is #4035/#4053 — but
"there is exactly one copy of it, and extracting it did not change either call
site's matching". The two sites differ on one axis only (separator handling),
and that asymmetry is load-bearing: erasing it would widen ``LocalSandbox``'s
masking or narrow ``sandbox.tools``'s.
The move itself was cleared by a differential against the *real* pre-extraction
expressions, run once on the parent commit. That run cannot be committed: after
this lands there is no old inline expression left to diff against, only the
frozen copies below. So the committed guard is the weaker snapshot, and its
red-ness rests on those literals — not on the length of ``_BASES``.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
from deerflow.sandbox.path_patterns import build_output_mask_pattern
from deerflow.sandbox.tools import _compiled_mask_patterns
def _legacy_tools_pattern(base: str) -> re.Pattern[str]:
"""The expression ``_compiled_mask_patterns`` inlined before the extraction."""
escaped = re.escape(base).replace(r"\\", r"[/\\]")
return re.compile(escaped + r"(?=/|$|[^\w./-])" + r"(?:[/\\][^\s\"';&|<>()]*)?")
def _legacy_local_pattern(base: str) -> re.Pattern[str]:
"""The expression ``_reverse_output_patterns`` inlined before the extraction."""
return re.compile(re.escape(base) + r"(?=/|$|[^\w./-])" + r"(?:[/\\][^\s\"';&|<>()]*)?")
_BASES = [
"/host/skills",
"/host/dir with spaces",
"/host/re+meta(chars)[x]",
"/host/dots.in.name",
"/Users/a/.deer-flow/users/u1/threads/t1/user-data",
"C:\\host\\skills",
"/host/技能",
# Drive root: the only base either caller can hand the helper that still ends in a
# separator (``Path.resolve()`` strips them everywhere else), so it is the one shape
# that goes red if the helper starts normalizing the base it is given.
"C:\\",
]
@pytest.mark.parametrize("base", _BASES)
def test_helper_reproduces_the_pre_extraction_expressions(base: str) -> None:
"""Byte-identical to what each call site built inline, for both separator modes.
This is the anchor for the move itself: edit the helper in a way that changes
either site's regex and this goes red.
"""
assert build_output_mask_pattern(base, separator_agnostic=True).pattern == _legacy_tools_pattern(base).pattern
assert build_output_mask_pattern(base).pattern == _legacy_local_pattern(base).pattern
def test_separator_agnostic_is_the_only_difference_between_the_two_modes() -> None:
"""The asymmetry the helper must preserve rather than unify.
``sandbox.tools`` derives bases from ``_path_variants`` (Windows spellings)
and matches them against output whose separators it does not control, so a
``\\``-spelled base must still match ``/``-spelled output. ``LocalSandbox``
resolves its bases from the running platform and must not be widened.
"""
windows_base = "C:\\host\\skills"
posix_spelling = "C:/host/skills/file.md"
assert build_output_mask_pattern(windows_base, separator_agnostic=True).search(posix_spelling)
assert build_output_mask_pattern(windows_base).search(posix_spelling) is None
# On a base with no separator ambiguity the two modes agree exactly.
posix_base = "/host/skills"
assert build_output_mask_pattern(posix_base, separator_agnostic=True).pattern == build_output_mask_pattern(posix_base).pattern
def test_boundary_still_rejects_prefix_siblings_and_accepts_real_segments() -> None:
"""The #4035/#4053 rule itself, now asserted once against the shared helper."""
pattern = build_output_mask_pattern("/host/skills")
# Matches: the root itself, a child, a Windows-separated child, and a root
# followed by text punctuation (``$`` and the ``[^\w./-]`` class).
assert pattern.fullmatch("/host/skills")
assert pattern.match("/host/skills/a/b.md")
assert pattern.match("/host/skills\\a\\b.md")
assert pattern.search("paths: /host/skills, and more")
# Does not match inside a sibling that merely shares the prefix.
assert pattern.search("/host/skills-extra/file.md") is None
assert pattern.search("/host/skills.bak") is None
assert pattern.search("/host/skills2/file.md") is None
def test_local_sandbox_reverse_patterns_route_through_the_helper(tmp_path: Path) -> None:
"""Call-site wiring: a re-inlined copy that *diverges* from the shared rule goes red.
It does not (and cannot) catch a byte-identical re-inline — that is not yet a
defect. What it catches is the shape of the actual regression: #4035 changed
one copy of the rule and left the other behind.
"""
local = tmp_path / "skills"
local.mkdir()
sandbox = LocalSandbox(
id="local",
path_mappings=[PathMapping(container_path="/mnt/skills", local_path=str(local), read_only=True)],
)
resolved = str(Path(local).resolve())
assert [p.pattern for p in sandbox._reverse_output_patterns] == [build_output_mask_pattern(resolved).pattern]
def test_tools_mask_patterns_route_through_the_helper(tmp_path: Path) -> None:
"""Same wiring check for the other copy — and it must stay separator-agnostic."""
host = tmp_path / "skills"
host.mkdir()
compiled = _compiled_mask_patterns(((str(host), "/mnt/skills"),))
assert compiled
for pattern, variant, virtual_base in compiled:
assert virtual_base == "/mnt/skills"
assert pattern.pattern == build_output_mask_pattern(variant, separator_agnostic=True).pattern
+135 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from types import SimpleNamespace
from langchain.agents.middleware.types import ModelRequest
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
from deerflow.agents.middlewares import skill_activation_middleware as middleware_module
@@ -221,6 +221,140 @@ def test_skill_activation_middleware_dedupes_immediately_previous_activation_wit
assert sum(is_slash_skill_activation_reminder(message) for message in captured["messages"]) == 1
def test_skill_activation_middleware_activates_once_across_tool_loop(monkeypatch, tmp_path):
# Regression for the re-activation bug: within a single run the model node is
# invoked once per tool-loop step, each time rebuilding request.messages fresh
# from persisted graph state. The activation reminder is added via
# request.override(messages=...) for one model call only and is NEVER written
# back to state, so the 2nd model call's state is [user, ai(tool_call), tool]
# with no reminder to scan. Dedup must therefore key off the run context, which
# LangGraph threads through every model-node call of the run.
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))
recorded = []
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: recorded.append(kwargs))
# One run context object, shared across every model call of the turn.
runtime = SimpleNamespace(context={"__run_journal": journal})
middleware = SkillActivationMiddleware()
user = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
# --- model call 1: the single activation call ---
first_capture = {}
def first_handler(model_request: ModelRequest):
first_capture["messages"] = model_request.messages
return AIMessage(content="", tool_calls=[{"name": "echo", "args": {}, "id": "call-1"}], id="ai-1")
first_result = middleware.wrap_model_call(_make_model_request([user], runtime=runtime), first_handler)
assert isinstance(first_result, AIMessage)
assert sum(is_slash_skill_activation_reminder(message) for message in first_capture["messages"]) == 1
# --- model call 2: same turn, real state after the tool result comes back.
# The reminder from call 1 is gone (never persisted), exactly as create_agent
# rebuilds it. It must NOT be re-injected. ---
ai_tool_call = first_result
tool_result = ToolMessage(content="echoed", tool_call_id="call-1", id="tool-1")
second_capture = {}
def second_handler(model_request: ModelRequest):
second_capture["messages"] = model_request.messages
return AIMessage(content="final answer", id="ai-2")
second_result = middleware.wrap_model_call(_make_model_request([user, ai_tool_call, tool_result], runtime=runtime), second_handler)
assert isinstance(second_result, AIMessage)
assert second_capture["messages"] == [user, ai_tool_call, tool_result]
assert sum(is_slash_skill_activation_reminder(message) for message in second_capture["messages"]) == 0
# Skill read from disk once and the activation audit event recorded once for the
# whole multi-call turn.
assert disk_reads["n"] == 1
assert sum(1 for kwargs in recorded if kwargs.get("action") == "activate") == 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
# activates even though an earlier slash message already activated in this run.
skill_a = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
skill_b = _make_skill(tmp_path, "frontend-design", content="# Frontend Design\nUse react.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill_a, skill_b]))
recorded = []
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: recorded.append(kwargs))
runtime = SimpleNamespace(context={"__run_journal": journal})
middleware = SkillActivationMiddleware()
first_msg = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
first_capture = {}
def first_handler(model_request: ModelRequest):
first_capture["messages"] = model_request.messages
return AIMessage(content="done", id="ai-1")
middleware.wrap_model_call(_make_model_request([first_msg], runtime=runtime), first_handler)
first_reminders = [message for message in first_capture["messages"] if is_slash_skill_activation_reminder(message)]
assert len(first_reminders) == 1
assert "Use pandas." in first_reminders[0].content
# New user turn in the same run context: a different slash command must activate.
second_msg = HumanMessage(content="/frontend-design build a form", id="msg-2")
second_capture = {}
def second_handler(model_request: ModelRequest):
second_capture["messages"] = model_request.messages
return AIMessage(content="done", id="ai-2")
middleware.wrap_model_call(
_make_model_request([first_msg, AIMessage(content="done", id="ai-1"), second_msg], runtime=runtime),
second_handler,
)
second_reminders = [message for message in second_capture["messages"] if is_slash_skill_activation_reminder(message)]
assert len(second_reminders) == 1
assert "Use react." in second_reminders[0].content
# Two distinct activations recorded across the run.
assert sum(1 for kwargs in recorded if kwargs.get("action") == "activate") == 2
def test_skill_activation_middleware_activates_per_call_when_run_context_is_none(monkeypatch, tmp_path):
# Regression for the degraded-path contract: when runtime.context is None
# (e.g. no run-scoped context is threaded through - the #3989 case), _run_context()
# normalizes it to None and the run-scoped dedup guard (_already_activated) must
# treat that as "nothing recorded yet" rather than crashing or wrongly skipping
# activation. The middleware should gracefully fall back to the original
# per-call activation behavior - no worse than before the run-context dedup
# was introduced.
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
runtime = SimpleNamespace(context=None)
captured = {}
def handler(model_request: ModelRequest):
captured["messages"] = model_request.messages
return AIMessage(content="ok")
result = middleware.wrap_model_call(_make_model_request([original], runtime=runtime), handler)
assert isinstance(result, AIMessage)
assert result.content == "ok"
activation_msg, user_msg = captured["messages"]
assert is_slash_skill_activation_reminder(activation_msg)
assert "Use pandas." in activation_msg.content
assert user_msg.content == original.content
def test_skill_activation_middleware_async_injects_hidden_human_context_for_model_call(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
+2 -2
View File
@@ -32,7 +32,7 @@ export function Hero({ className }: { className?: string }) {
return (
<div
className={cn(
"flex size-full flex-col items-center justify-center",
"relative flex size-full flex-col items-center justify-center",
className,
)}
>
@@ -47,7 +47,7 @@ export function Hero({ className }: { className?: string }) {
/>
</div>
<FlickeringGrid
className="absolute inset-0 z-0 translate-y-8 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
className="absolute inset-0 z-0 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
squareSize={4}
gridGap={4}
color={"white"}