diff --git a/docs/mkdocs/en/skill.md b/docs/mkdocs/en/skill.md index 05375e7d..cd408f12 100644 --- a/docs/mkdocs/en/skill.md +++ b/docs/mkdocs/en/skill.md @@ -1016,6 +1016,12 @@ Assistant: Let me check what documentation is available. - Collects specified output files and returns them - Supports custom environment variable overrides +Both `skill_run` and `skill_exec` call `repository.skill_run_env(skill_name)` +to add skill-specific environment variables. Values returned by the hook do not +override keys already present in the call's `env` or non-empty host environment +values. Empty keys, blank values, and blocked keys such as `LD_PRELOAD` are ignored. +If the hook raises an exception, command execution continues. + **Prompt guidance**: This tool is called by the LLM when ready to execute actual commands. The prompt should include detailed usage guidelines: diff --git a/docs/mkdocs/zh/skill.md b/docs/mkdocs/zh/skill.md index 8bd8c092..86754196 100644 --- a/docs/mkdocs/zh/skill.md +++ b/docs/mkdocs/zh/skill.md @@ -1009,6 +1009,11 @@ Assistant: Let me check what documentation is available. - 收集指定的输出文件并返回 - 支持自定义环境变量覆盖 +`skill_run` 和 `skill_exec` 都会调用 `repository.skill_run_env(skill_name)` +补充 Skill 专属环境变量。钩子返回的变量不会覆盖调用参数 `env` 中已有的键,也不会 +覆盖宿主环境中的非空值。空键、空白值以及禁止注入的键(如 `LD_PRELOAD`)会被忽略; +钩子抛出异常时,命令仍继续执行。 + **提示词指导**: 这个工具由 LLM 在准备好后执行实际命令。提示词应包含详细的使用指南: diff --git a/tests/skills/tools/test_skill_exec.py b/tests/skills/tools/test_skill_exec.py index 8aaf0cdb..a5d9de9d 100644 --- a/tests/skills/tools/test_skill_exec.py +++ b/tests/skills/tools/test_skill_exec.py @@ -4,6 +4,7 @@ # # tRPC-Agent-Python is licensed under Apache-2.0. +import os from unittest.mock import AsyncMock from unittest.mock import MagicMock @@ -13,10 +14,14 @@ from trpc_agent_sdk.code_executors import DEFAULT_IO_YIELD_MS from trpc_agent_sdk.code_executors import DEFAULT_POLL_LINES from trpc_agent_sdk.code_executors import DEFAULT_SESSION_TTL_SEC +from trpc_agent_sdk.code_executors import ENV_SKILL_NAME +from trpc_agent_sdk.code_executors import WorkspaceRunResult +from trpc_agent_sdk.skills.stager import SkillStageResult from trpc_agent_sdk.skills.tools._skill_exec import ExecInput from trpc_agent_sdk.skills.tools._skill_exec import PollSessionTool from trpc_agent_sdk.skills.tools._skill_exec import SkillExecTool from trpc_agent_sdk.skills.tools._skill_exec import WriteStdinTool +from trpc_agent_sdk.skills.tools._skill_exec import _ExecSession from trpc_agent_sdk.skills.tools._skill_exec import _close_session from trpc_agent_sdk.skills.tools._skill_exec import _collect_final_result from trpc_agent_sdk.skills.tools._skill_exec import _detect_interaction @@ -25,6 +30,7 @@ from trpc_agent_sdk.skills.tools._skill_exec import _start_session from trpc_agent_sdk.skills.tools._skill_exec import create_exec_tools from trpc_agent_sdk.skills.tools._skill_run import SkillRunFile +from trpc_agent_sdk.skills.tools._skill_run import SkillRunTool def _make_exec_tool() -> SkillExecTool: @@ -40,6 +46,7 @@ def _make_exec_tool() -> SkillExecTool: class TestHelpers: + def test_last_non_empty_line(self): assert _last_non_empty_line("a\n\nb\n") == "b" @@ -59,6 +66,7 @@ def test_detect_interaction_selection(self): class TestModelsAndConstants: + def test_exec_input_defaults(self): inp = ExecInput(skill="s", command="echo hi") assert inp.yield_time_ms == 0 @@ -73,6 +81,7 @@ def test_default_constants(self): class TestSessionStore: + @pytest.mark.asyncio async def test_put_get_remove(self): tool = _make_exec_tool() @@ -87,6 +96,7 @@ async def test_put_get_remove(self): class TestFactoryAndDeclarations: + def test_create_exec_tools(self): run_tool = MagicMock() run_tool._repository = MagicMock() @@ -105,6 +115,7 @@ def test_declaration_names(self): class TestCloseSession: + @pytest.mark.asyncio async def test_close_session(self): sess = MagicMock() @@ -113,6 +124,68 @@ async def test_close_session(self): sess.proc.close.assert_awaited_once() +class TestExecutionEnvironment: + + @pytest.mark.asyncio + @pytest.mark.parametrize("tool_name", ["skill_run", "skill_exec"]) + @pytest.mark.parametrize( + ("call_env", "host_env", "expected"), + [ + pytest.param({}, None, "injected", id="repository"), + pytest.param({"TEST_SKILL_ENV": "explicit"}, None, "explicit", id="explicit"), + pytest.param({}, "host", "host", id="host"), + pytest.param({"TEST_SKILL_ENV": "explicit"}, "host", "explicit", id="explicit-over-host"), + pytest.param({}, " ", "injected", id="blank-host"), + pytest.param({"TEST_SKILL_ENV": ""}, None, "", id="empty-explicit"), + ], + ) + async def test_skill_tools_apply_repository_env(self, tmp_path, monkeypatch, tool_name, call_env, host_env, + expected): + if host_env is None: + monkeypatch.delenv("TEST_SKILL_ENV", raising=False) + else: + monkeypatch.setenv("TEST_SKILL_ENV", host_env) + + ctx = MagicMock() + ctx.agent_name = "" + ctx.actions.state_delta = {} + ctx.session_state = {} + ws = MagicMock(path=str(tmp_path)) + workspace_runtime = MagicMock(spec=BaseWorkspaceRuntime) + workspace_runtime.manager.return_value.create_workspace = AsyncMock(return_value=ws) + workspace_runtime.fs.return_value.collect = AsyncMock(return_value=[]) + runner = workspace_runtime.runner.return_value + runner.run_program = AsyncMock(return_value=WorkspaceRunResult(stdout="done", exit_code=0)) + runner.start_program = AsyncMock(return_value=MagicMock()) + + repository = MagicMock() + repository.get_workspace_runtime.return_value = workspace_runtime + repository.skill_run_env.return_value = {"TEST_SKILL_ENV": "injected"} + run_tool = SkillRunTool(repository=repository) + run_tool.skill_stager.stage_skill = AsyncMock(return_value=SkillStageResult(workspace_skill_dir="skills/test")) + monkeypatch.setattr(_ExecSession, "yield_output", AsyncMock(return_value=("running", "", 0, 0))) + tool = run_tool if tool_name == "skill_run" else SkillExecTool(run_tool) + + await tool._run_async_impl( + tool_context=ctx, + args={ + "skill": " test ", + "command": "echo done", + "env": call_env + }, + ) + + if tool_name == "skill_run": + env = runner.run_program.call_args.args[1].env + effective_env = os.environ.copy() + effective_env.update(env) + else: + effective_env = runner.start_program.call_args.args[2].env + assert effective_env["TEST_SKILL_ENV"] == expected + assert effective_env[ENV_SKILL_NAME] == "test" + repository.skill_run_env.assert_called_once_with("test") + + @pytest.mark.asyncio async def test_collect_final_result_passes_workspace_runtime_and_returns_files(): ctx = MagicMock() diff --git a/tests/skills/tools/test_skill_run.py b/tests/skills/tools/test_skill_run.py index 92cf9e02..0bcaab98 100644 --- a/tests/skills/tools/test_skill_run.py +++ b/tests/skills/tools/test_skill_run.py @@ -15,6 +15,7 @@ from trpc_agent_sdk.skills.tools._skill_run import SkillRunInput from trpc_agent_sdk.skills.tools._skill_run import SkillRunOutput from trpc_agent_sdk.skills.tools._skill_run import SkillRunTool +from trpc_agent_sdk.skills.tools._skill_run import _apply_skill_run_env from trpc_agent_sdk.skills.tools._skill_run import _build_editor_wrapper_script from trpc_agent_sdk.skills.tools._skill_run import _filter_failed_empty_outputs from trpc_agent_sdk.skills.tools._skill_run import _is_text_mime @@ -32,6 +33,7 @@ def _make_tool() -> SkillRunTool: class TestSchemaHelpers: + def test_inline_json_schema_refs(self): schema = {"$defs": {"X": {"type": "string"}}, "properties": {"x": {"$ref": "#/$defs/X"}}} out = inline_json_schema_refs(schema) @@ -40,6 +42,54 @@ def test_inline_json_schema_refs(self): class TestModuleHelpers: + + def test_apply_skill_run_env_filters_empty_entries(self, monkeypatch): + monkeypatch.delenv("TEST_SKILL_ENV", raising=False) + repository = MagicMock() + repository.skill_run_env.return_value = { + " TEST_SKILL_ENV ": " injected ", + "": "ignored", + " ": "ignored", + "TEST_EMPTY_ENV": " ", + } + env = {} + + _apply_skill_run_env(repository, "test", env) + + assert env == {"TEST_SKILL_ENV": " injected "} + repository.skill_run_env.assert_called_once_with("test") + + @pytest.mark.parametrize( + "key", + [ + "LD_PRELOAD", + "ld_preload", + "LD_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "DYLD_FORCE_FLAT_NAMESPACE", + "OPENSSL_CONF", + ], + ) + def test_apply_skill_run_env_blocks_loader_keys(self, monkeypatch, key): + monkeypatch.delenv(key, raising=False) + repository = MagicMock() + repository.skill_run_env.return_value = {key: "blocked"} + env = {} + + _apply_skill_run_env(repository, "test", env) + + assert env == {} + + def test_apply_skill_run_env_ignores_repository_errors(self): + repository = MagicMock() + repository.skill_run_env.side_effect = RuntimeError("repository env unavailable") + env = {"TEST_SKILL_ENV": "explicit"} + + _apply_skill_run_env(repository, "test", env) + + assert env == {"TEST_SKILL_ENV": "explicit"} + def test_is_text_mime(self): assert _is_text_mime("text/plain") is True assert _is_text_mime("application/json") is True @@ -111,6 +161,7 @@ def test_build_editor_wrapper_script(self): class TestModels: + def test_run_models(self): inp = SkillRunInput(skill="s", command="echo hi") out = SkillRunOutput() @@ -121,6 +172,7 @@ def test_run_models(self): class TestSkillRunToolBasics: + def test_resolve_cwd(self): tool = _make_tool() assert tool._resolve_cwd("", "skills/x") == "skills/x" diff --git a/trpc_agent_sdk/skills/tools/_skill_exec.py b/trpc_agent_sdk/skills/tools/_skill_exec.py index d941dfac..d73ae84d 100644 --- a/trpc_agent_sdk/skills/tools/_skill_exec.py +++ b/trpc_agent_sdk/skills/tools/_skill_exec.py @@ -91,6 +91,7 @@ from ._skill_run import SkillRunInput from ._skill_run import SkillRunOutput from ._skill_run import SkillRunTool +from ._skill_run import _apply_skill_run_env from ._skill_run import _filter_failed_empty_outputs from ._skill_run import _select_primary_output from ._skill_run import _truncate_output @@ -447,6 +448,7 @@ async def _run_async_impl( extra_env: dict[str, str] = dict(inputs.env) if ENV_SKILL_NAME not in extra_env: extra_env[ENV_SKILL_NAME] = normalized_skill + _apply_skill_run_env(repository, normalized_skill, extra_env) merged_env = _build_exec_env(ws, extra_env) # Start interactive program session via runtime runner. diff --git a/trpc_agent_sdk/skills/tools/_skill_run.py b/trpc_agent_sdk/skills/tools/_skill_run.py index 5525e244..7759afe3 100644 --- a/trpc_agent_sdk/skills/tools/_skill_run.py +++ b/trpc_agent_sdk/skills/tools/_skill_run.py @@ -116,6 +116,25 @@ # --------------------------------------------------------------------------- +def _apply_skill_run_env(repository: BaseSkillRepository, skill_name: str, env: dict[str, str]) -> None: + """Inject skill-specific env without overriding explicit or non-empty host values.""" + try: + skill_env: dict[str, str] = repository.skill_run_env(skill_name) + for k, v in skill_env.items(): + k = k.strip() + if not k or not v.strip(): + continue + if k in env: # don't override explicit tool-call env + continue + if os.environ.get(k, "").strip(): # don't override host env + continue + if k.upper() in _BLOCKED_SKILL_ENV_KEYS: + continue + env[k] = v + except Exception: # pylint: disable=broad-except + pass + + def _is_text_mime(mime: str) -> bool: """Return True when *mime* is a text-like content type.""" if not mime: @@ -770,21 +789,7 @@ async def _run_program( # Inject skill-specific env from repository (e.g. api_key → primary_env) repository = self._get_repository(ctx) - try: - skill_env: dict[str, str] = repository.skill_run_env(input_data.skill) - for k, v in skill_env.items(): - k = k.strip() - if not k or not v.strip(): - continue - if k in env: # don't override explicit tool-call env - continue - if os.environ.get(k, "").strip(): # don't override host env - continue - if k.upper() in _BLOCKED_SKILL_ENV_KEYS: - continue - env[k] = v - except Exception: # pylint: disable=broad-except - pass + _apply_skill_run_env(repository, input_data.skill, env) # Stage editor helper if requested await self._prepare_editor_env(ctx, ws, workspace_runtime, env, input_data.editor_text)