Repository navigation
[BUG] skill_exec does not apply repository.skill_run_env() #359
Description
问题描述
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
相关代码
skill_run环境注入:调用 repository 钩子,并保留显式 env、宿主非空值和禁止键过滤规则。skill_exec环境构造:从inputs.env构造extra_env后直接调用_build_exec_env()。- 官方
SkillLoader实现。
#95 修复的是 skill_run 路径中的钩子调用签名,#356 修复的是 skill_exec 的产物收集;当前遗漏仍可在上述 commit 复现。
修复建议
将现有 repository 环境注入逻辑抽成内部共享 helper,供两个入口调用,并在 skill_exec 中于 _build_exec_env() 前注入。保留现有的优先级、空值过滤、禁止键过滤及钩子异常处理,避免两个入口的逻辑继续漂移。
Activity
kinolo commentedon Oct 10, 2026
已提交修复 PR #360,统一两个执行入口的环境变量注入逻辑,并补充了回归测试,欢迎 review。
fix: 修复skill_exec未注入Skill专属环境变量的问题 (trpc-group#359)