tool isolation between Skills:

How can I detect when a DeepAgents Skill (loaded from MD files) finishes execution? I need to implement strict tool isolation between Skills: when one Skill is running, the agent can only use its allowed tools, and the tool list must be restored immediately after the Skill ends, not after the entire agent turn ends.

Hi @chenkejin

What you’re asking for doesn’t map onto Skills as Deep Agents implements them, and trying to force it will be fragile.

Skills are prompt fragments loaded by progressive disclosure, not runtime execution units - there is no skill_started / skill_ended event to hook, and the allowed-tools field in SKILL.md is, today, only a hint printed in the system prompt; it is not enforced by SkillsMiddleware.

If you genuinely need strict, enforceable, per-capability tool scoping with a clean “start” and “end”, the idiomatic fix is to model each Skill as a Subagent (SubAgentMiddleware / the task tool).

If you must keep Skills as SKILL.md files, you can approximate scoping with a custom middleware that overrides request.tools via request.override(tools=...) based on a state flag - but you have to generate the start/end signal yourself.

Below are the three viable paths, ranked by how well they satisfy your real requirements.

1. Skills are system-prompt content, not a subgraph

SkillsMiddleware loads each SKILL.md frontmatter ahead of time and injects a “Skills System” section into the system prompt containing names, descriptions, paths, and - if present - a textual listing of allowed-tools. The agent then reads the full skill file via read_file inside its normal turn and continues calling tools from the single, already-bound toolset. There’s no “enter skill” and “exit skill” node in the graph at all.

Relevant code - sources/deepagents/libs/deepagents/deepagents/middleware/skills.py:

# The "Skills System" text is appended to the system message every model call.
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
    skills_metadata = request.state.get("skills_metadata", [])
    skills_locations = self._format_skills_locations()
    skills_list = self._format_skills_list(skills_metadata)
    skills_section = self.system_prompt_template.format(
        skills_locations=skills_locations,
        skills_list=skills_list,
    )
    new_system_message = append_to_system_message(request.system_message, skills_section)
    return request.override(system_message=new_system_message)

And the rendering of allowed_tools - it’s pure text, never consulted by a tool gate:

# _format_skills_list
if skill["allowed_tools"]:
    lines.append(f"  -> Allowed tools: {', '.join(skill['allowed_tools'])}")

Docs confirming this: the agent “reads the full SKILL.md file using the path shown in its skills list” and then “follows the skill’s instructions” - all inside the same turn, same tool binding.
https://docs.langchain.com/oss/python/deepagents/skills

Consequences for your ask:

  • There is no lifecycle signal you can subscribe to that says “skill X just finished”. The model alone decides when it’s “done with the skill” and silently moves on.
  • Even if you could reliably detect the read of SKILL.md, the model keeps calling tools from the same flat toolset; your “restore immediately after the Skill ends” has nowhere to hook.

2. allowed-tools is currently advisory, not enforced

From the parser, allowed-tools is captured and then surfaced as a line in the system prompt. There is no _check_allowed_tools or wrap_tool_call that rejects a disallowed call anywhere in SkillsMiddleware (the tests in tests/unit_tests/middleware/test_skills_middleware.py exercise parsing only, not enforcement). A jailbreak is just the model ignoring the paragraph you printed.

This is labelled in the type itself: allowed_tools is annotated Warning: this is experimental in SkillMetadata (skills.py, line ~186).

So relying on allowed-tools for isolation is, today, purely a prompt-level guideline.

Model each Skill as a Subagent

This is the only path that gives you actual, framework-enforced tool isolation and a clean start/end boundary.

  • Subagents are compiled with their own tools list; the parent model never sees those tools, and the subagent’s model never sees the parent’s tools.
  • The “start” is the task(subagent_type=...) tool call; the “end” is the subagent returning its final message - you get exactly the “restored immediately after the skill ends” semantic you asked for, because once the task tool returns, the main agent is back with its own original tools.

Minimal shape:

from deepagents import create_deep_agent

web_research = {
    "name": "web-research",
    "description": "Structured web research workflow (use when the user asks to research a topic).",
    "system_prompt": open("./skills/web-research/SKILL.md").read(),  # move the skill body here
    "model": "openai:gpt-4.1",
    "tools": [web_search, read_file],        # <-- the only tools this skill can call
}

sql_helper = {
    "name": "sql-helper",
    "description": "Answer questions that require SQL against the internal warehouse.",
    "system_prompt": open("./skills/sql-helper/SKILL.md").read(),
    "model": "openai:gpt-4.1",
    "tools": [run_sql, describe_table],      # <-- completely disjoint toolset
}

agent = create_deep_agent(
    model="openai:gpt-4.1",
    tools=[],                    # main agent has no domain tools, only `task`
    subagents=[web_research, sql_helper],
)

Why this is the right answer:

  • Tool isolation is enforced at the graph level, not by prompt discipline. A subagent physically cannot emit a tool call the parent owns.
  • The “skill end” is a deterministic, framework-provided event: it’s the ToolMessage returned for the task call. You can hook it with wrap_tool_call on the parent if you need a callback; see the custom-middleware pages.
  • You keep progressive disclosure: the parent only sees {name, description} in the task tool description - the full SKILL.md body only ever enters the subagent’s context.
  • The docs explicitly support this layering - see “Skills for subagents” and note that skill state is fully isolated: the main agent’s skills are not visible to subagents, and subagent skills are not visible to the main agent.
    https://docs.langchain.com/oss/python/deepagents/skills#skills-for-subagents
    https://docs.langchain.com/oss/python/deepagents/subagents

Trade-offs to be aware of:

  • Each “skill call” is a full extra LLM round-trip (spawn, run, return). For very short skills this is more expensive than just reading a markdown section inline.
  • Subagent invocations are stateless across calls (per TASK_TOOL_DESCRIPTION in subagents.py, line 203: “Each agent invocation is stateless”). Persist anything you need across calls in the filesystem/state backend.

If you want to keep authoring your skills as SKILL.md files (with frontmatter), write a small loader that produces the SubAgent dicts from disk - the SKILL.md format is the same; only the consumer changes from SkillsMiddleware to SubAgentMiddleware.

Dynamic tool scoping via custom middleware

If converting to subagents is too invasive, you can scope tools per model call by writing middleware that overrides request.tools based on a state field the model sets itself. This is the officially documented escape hatch for “modify the set of tools available to the agent at runtime”:

https://docs.langchain.com/oss/python/langchain/agents#dynamic-tools
https://docs.langchain.com/oss/python/langchain/middleware/custom

Shape:

from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
from langchain_core.tools import tool
from langgraph.types import Command

class SkillScopeMiddleware(AgentMiddleware):
    """Scope `request.tools` to the currently active skill, if any."""

    def __init__(self, skill_tool_map: dict[str, set[str]]) -> None:
        super().__init__()
        # e.g. {"web-research": {"web_search", "read_file"}, ...}
        self._skill_tool_map = skill_tool_map

    def wrap_model_call(self, request: ModelRequest, handler):
        active = request.state.get("active_skill")
        if active and active in self._skill_tool_map:
            allowed = self._skill_tool_map[active]
            # Keep framework/control tools (task, todo, filesystem) - only gate domain tools
            filtered = [t for t in request.tools if t.name in allowed or t.name in CONTROL_TOOLS]
            request = request.override(tools=filtered)
        return handler(request)

You also need the model to signal start and end explicitly, because the framework won’t:

@tool
def begin_skill(name: str) -> Command:
    """Enter a skill's tool scope. Call before using a skill's capabilities."""
    return Command(update={"active_skill": name})

@tool
def end_skill() -> Command:
    """Leave the current skill's tool scope. Call as soon as the skill is finished."""
    return Command(update={"active_skill": None})

Then add a sentence to each SKILL.md: “Call begin_skill("<name>") before you do anything, and end_skill() as soon as you finish.”

Why this is strictly weaker than subagents:

  • Model-driven, not framework-driven. If the model forgets to call end_skill, scope never restores. You can harden this by having a hook (after_model) clear active_skill when the model returns a non-tool-call message, but that’s still best-effort.
  • Same context window. You don’t get the token savings of a subagent.
  • Harder to audit. There’s no trace “this call happened under skill X” unless you add it.

Use this only if you can’t pay the latency/cost of a subagent round-trip.

Key API reference for the middleware path

  • ModelRequest.override(tools=...) - the supported way to swap tools mid-request. Examples of this exact pattern live in langchain/libs/langchain_v1/langchain/agents/middleware/tool_selection.py (LLMToolSelectorMiddleware._process_selection_response, line 272) and in the agents docs section “Dynamic tools” linked above.
  • wrap_tool_call - if you also want to block a disallowed call defensively (e.g., return a ToolMessage saying “not allowed in skill X”) instead of just hiding the tool from the model.

Hybrid pattern (often the pragmatic choice)

Keep SkillsMiddleware for discovery and documentation of your skill library, but declare each skill that needs real isolation twice:

  1. Once as a SKILL.md under /skills/.../ (so it shows up in the progressive-disclosure listing and can be authored like any other skill).
  2. Once as a Subagent whose system_prompt is the body of that same SKILL.md. The main agent’s prompt can say: “For skill X, prefer calling task(subagent_type='X') over reading SKILL.md inline.”

That way low-stakes skills stay cheap (inline read), and high-stakes skills - the ones where allowed-tools actually matter

Direct answers to your two requirements

  1. “Detect when a Skill finishes execution” - there is no such event for SkillsMiddleware-loaded skills. The only deterministic “finish” signal available today is a Subagent returning (the ToolMessage produced by the task tool). Hook it with wrap_tool_call on the parent if you need a callback.

  2. “Tool list restored immediately after the Skill ends, not after the turn” - the Subagent approach gives this for free (tools are restored the instant the task tool returns, not at end-of-turn). The middleware approach gives it approximately, but only if the model reliably calls your end_skill tool - which is a prompt-discipline problem.

Sources