Skip to content

[BUG] skill_exec does not apply repository.skill_run_env() #359

Open

Description

@kinolo

问题描述

SkillRunTool._run_program() 会调用 repository.skill_run_env(skill_name) 注入 Skill 的环境变量,但 SkillExecTool._run_async_impl() 未调用该钩子。

因此,同一个 Skill 的变量如果仅由 repository 提供,使用 skill_run 时可读,切换到 skill_exec 后就缺失。上游 OpenClaw 的 SkillLoader 已实现此钩子,用于返回每个 Skill 配置的环境变量,所以该差异也影响官方 repository 扩展点。

复现环境

  • 当前 main:8ce3612291f85d963caaf4f87ddeab74f2d7e017(已包含 fix: 修复skill_exec 工具调用中产物丢失的问题 (#350) #356)
  • Linux,Python 3.12.14
  • create_local_workspace_runtime(),真实本地子进程
  • 自定义 FsSkillRepository 子类,钩子返回 {"TEST_SKILL_ENV": "injected"}
  • 使用公开的 tool.run_async() 调用两个入口,无 LLM 调用,也未修改 SDK 代码

实际结果

执行的 Skill 脚本为:

import os
print(os.environ.get("TEST_SKILL_ENV"))
条件 skill_run stdout skill_exec stdout
变量仅由 repository 钩子提供 injected None
显式传入 env={"TEST_SKILL_ENV": "explicit"},宿主无该变量 explicit explicit
宿主设置 TEST_SKILL_ENV=host,未显式传入该变量 host host

六次执行的 exit code 均为 0。通过记录钩子调用还确认:每次 skill_run 都调用了 skill_run_env("env-demo");每次 skill_exec 都未调用该钩子。

预期: 第一行中两个入口都输出 injected;继续保留显式参数、宿主非空值和禁止注入键等现有语义。

完整复现脚本(已实际运行)

在上述 commit 的仓库中安装 SDK 后,将以下代码保存为 repro_skill_env.py 并运行 python repro_skill_env.py。脚本会创建临时 Skill 和工作区,运行三个条件下的两个入口,打印结果并校验当前差异。

import asyncio
import json
import os
import shlex
import sys
import tempfile
import uuid
from pathlib import Path

from trpc_agent_sdk.agents import BaseAgent
from trpc_agent_sdk.code_executors import create_local_workspace_runtime
from trpc_agent_sdk.context import AgentContext, InvocationContext
from trpc_agent_sdk.sessions import InMemorySessionService
from trpc_agent_sdk.skills import FsSkillRepository
from trpc_agent_sdk.skills.tools import SkillRunTool, SkillExecTool
from trpc_agent_sdk.skills.tools._skill_exec import PollSessionTool, KillSessionTool

KEY = "TEST_SKILL_ENV"

class NoopAgent(BaseAgent):
    async def _run_async_impl(self, ctx):
        if False:
            yield

class EnvRepository(FsSkillRepository):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.calls = []

    def skill_run_env(self, skill_name: str) -> dict[str, str]:
        self.calls.append(skill_name)
        return {KEY: "injected"}

async def main():
    previous = os.environ.pop(KEY, None)
    service = InMemorySessionService()
    agent = NoopAgent(name="env-repro")
    results = []
    try:
        with tempfile.TemporaryDirectory(prefix="skill-env-repro-") as root:
            skill = Path(root) / "skills" / "env-demo"
            skill.mkdir(parents=True)
            (skill / "SKILL.md").write_text("---\nname: env-demo\ndescription: Environment parity reproduction\n---\nPrint a variable.\n")
            (skill / "check.py").write_text("import os\nprint(os.environ.get('TEST_SKILL_ENV'))\n")
            runtime = create_local_workspace_runtime(work_root=str(Path(root) / "workspaces"))
            repo = EnvRepository(str(skill.parent), workspace_runtime=runtime)
            run = SkillRunTool(repository=repo)
            execute = SkillExecTool(run)
            poll = PollSessionTool(execute)
            kill = KillSessionTool(execute)
            command = f"{shlex.quote(sys.executable)} check.py"
            for case, explicit, host in [("repository_only", {}, None), ("explicit_override", {KEY: "explicit"}, None), ("host_override", {}, "host")]:
                if host is None:
                    os.environ.pop(KEY, None)
                else:
                    os.environ[KEY] = host
                for tool in [run, execute]:
                    session = await service.create_session(app_name="env-repro", user_id="repro-user", session_id=str(uuid.uuid4()))
                    ctx = InvocationContext(session_service=service, invocation_id=str(uuid.uuid4()), agent=agent, agent_context=AgentContext(), session=session)
                    repo.calls.clear()
                    args = {"skill": "env-demo", "command": command, "env": explicit}
                    if tool is execute:
                        args["yield_time_ms"] = 1000
                    out = await tool.run_async(tool_context=ctx, args=args)
                    if tool is execute:
                        sid = out["session_id"]
                        try:
                            for _ in range(10):
                                if out["status"] == "exited":
                                    break
                                out = await poll.run_async(tool_context=ctx, args={"session_id": sid, "yield_time_ms": 1000})
                            assert out["status"] == "exited", out
                            result = out["result"]
                        finally:
                            await kill.run_async(tool_context=ctx, args={"session_id": sid})
                    else:
                        result = out
                    assert result["exit_code"] == 0, result
                    row = {"case": case, "tool": tool.name, "stdout": result["stdout"].strip(), "exit_code": result["exit_code"], "hook_calls": list(repo.calls)}
                    results.append(row)
                    print(json.dumps(row), flush=True)
            observed = {(r["case"], r["tool"]): r["stdout"] for r in results}
            assert observed == {
                ("repository_only", "skill_run"): "injected",
                ("repository_only", "skill_exec"): "None",
                ("explicit_override", "skill_run"): "explicit",
                ("explicit_override", "skill_exec"): "explicit",
                ("host_override", "skill_run"): "host",
                ("host_override", "skill_exec"): "host",
            }, observed
            print("REPRO_CONFIRMED", flush=True)
    finally:
        if previous is None:
            os.environ.pop(KEY, None)
        else:
            os.environ[KEY] = previous

asyncio.run(main())

运行输出:

{"case": "repository_only", "tool": "skill_run", "stdout": "injected", "exit_code": 0, "hook_calls": ["env-demo"]}
{"case": "repository_only", "tool": "skill_exec", "stdout": "None", "exit_code": 0, "hook_calls": []}
{"case": "explicit_override", "tool": "skill_run", "stdout": "explicit", "exit_code": 0, "hook_calls": ["env-demo"]}
{"case": "explicit_override", "tool": "skill_exec", "stdout": "explicit", "exit_code": 0, "hook_calls": []}
{"case": "host_override", "tool": "skill_run", "stdout": "host", "exit_code": 0, "hook_calls": ["env-demo"]}
{"case": "host_override", "tool": "skill_exec", "stdout": "host", "exit_code": 0, "hook_calls": []}
REPRO_CONFIRMED

相关代码

#95 修复的是 skill_run 路径中的钩子调用签名,#356 修复的是 skill_exec 的产物收集;当前遗漏仍可在上述 commit 复现。

修复建议

将现有 repository 环境注入逻辑抽成内部共享 helper,供两个入口调用,并在 skill_exec 中于 _build_exec_env() 前注入。保留现有的优先级、空值过滤、禁止键过滤及钩子异常处理,避免两个入口的逻辑继续漂移。

Activity

  1. kinolo commented on Oct 10, 2026

    @kinolo
    Author

    已提交修复 PR #360,统一两个执行入口的环境变量注入逻辑,并补充了回归测试,欢迎 review。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions