From ee2959758978c143165919ee125adddd844ad057 Mon Sep 17 00:00:00 2001 From: raychen <815315825@qq.com> Date: Fri, 9 Oct 2026 11:04:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dskill=5Fexec=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E4=B8=AD=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1=E7=9A=84=E9=97=AE=E9=A2=98=20(#350)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/skills_with_exec_tool/.env | 4 + examples/skills_with_exec_tool/README.md | 118 ++++++++++++++++++ .../skills_with_exec_tool/agent/__init__.py | 5 + examples/skills_with_exec_tool/agent/agent.py | 38 ++++++ .../skills_with_exec_tool/agent/config.py | 19 +++ .../skills_with_exec_tool/agent/prompts.py | 23 ++++ examples/skills_with_exec_tool/agent/tools.py | 40 ++++++ examples/skills_with_exec_tool/run_agent.py | 89 +++++++++++++ .../skills/interactive-report/SKILL.md | 33 +++++ .../scripts/create_report.py | 40 ++++++ tests/skills/tools/test_skill_exec.py | 59 +++++++++ trpc_agent_sdk/skills/tools/_skill_exec.py | 18 ++- 12 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 examples/skills_with_exec_tool/.env create mode 100644 examples/skills_with_exec_tool/README.md create mode 100644 examples/skills_with_exec_tool/agent/__init__.py create mode 100644 examples/skills_with_exec_tool/agent/agent.py create mode 100644 examples/skills_with_exec_tool/agent/config.py create mode 100644 examples/skills_with_exec_tool/agent/prompts.py create mode 100644 examples/skills_with_exec_tool/agent/tools.py create mode 100644 examples/skills_with_exec_tool/run_agent.py create mode 100644 examples/skills_with_exec_tool/skills/interactive-report/SKILL.md create mode 100644 examples/skills_with_exec_tool/skills/interactive-report/scripts/create_report.py diff --git a/examples/skills_with_exec_tool/.env b/examples/skills_with_exec_tool/.env new file mode 100644 index 000000000..dc791393a --- /dev/null +++ b/examples/skills_with_exec_tool/.env @@ -0,0 +1,4 @@ +# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name diff --git a/examples/skills_with_exec_tool/README.md b/examples/skills_with_exec_tool/README.md new file mode 100644 index 000000000..352e55832 --- /dev/null +++ b/examples/skills_with_exec_tool/README.md @@ -0,0 +1,118 @@ +# SkillExecTool 示例 + +本示例使用单个 `interactive-report` Skill,验证 `SkillExecTool` 的交互式 +stdin、命令执行、输出文件收集和 Artifact 保存。 + +## 关键特性 + +- `skill_load` 加载唯一 Skill。 +- `skill_exec` 启动交互式 Python 程序,并通过初始 `stdin` 回答两个问题。 +- `output_files=["out/report.txt"]` 验证进程结束后的文件收集。 +- `InMemoryArtifactService` 验证 `save_as_artifacts` 和 `artifact_files`。 + +## Agent 层级结构说明 + +- 根节点:`LlmAgent`(`skill_exec_agent`)。 +- 挂载 `SkillToolSet` 和本地技能仓库,无子 Agent。 + +## 关键代码解释 + +- `run_agent.py`:要求模型按固定参数调用 `skill_exec`,并打印工具结果。 +- `agent/tools.py`:使用本地 Workspace Runtime 构造 `SkillToolSet`。 +- `skills/interactive-report/`:唯一 Skill,包含交互脚本和产物说明。 + +## stdin 如何工作 + +`run_agent.py` 要求模型在调用 `skill_exec` 时传入: + +```json +{ + "command": "python3 scripts/create_report.py", + "stdin": "release-1.2.0\n2\n" +} +``` + +`SkillExecTool` 会把该字符串作为进程启动时的初始 stdin。交互脚本连续调用两次 +`input()`: + +1. `release-1.2.0` 回答 `Release name`。 +2. `2` 回答 `Report mode`,表示生成详细报告。 + +其效果等价于: + +```bash +printf 'release-1.2.0\n2\n' | python3 scripts/create_report.py +``` + +这里演示的是启动时一次性提供 stdin。对于程序启动后才出现的动态问题,应先通过 +`skill_exec` 获取 `session_id`,再使用 `skill_write_stdin` 分次输入,并通过 +`skill_poll_session` 获取后续输出和最终产物。 + +## 环境要求 + +- Python3.10+,推荐 Python3.12 + +## 构建步骤 + +```bash +git clone https://github.com/trpc-group/trpc-agent-python.git +cd trpc-agent-python +./build.sh +source .venv/bin/activate +``` + +## 运行步骤 + +### 配置环境变量 + +通过环境变量或当前目录的 `.env` 配置: + +- `TRPC_AGENT_API_KEY` +- `TRPC_AGENT_BASE_URL` +- `TRPC_AGENT_MODEL_NAME` +- 可选:`SKILLS_ROOT` 指向技能根目录 + +### 运行命令 + +```bash +cd examples/skills_with_exec_tool +python3 run_agent.py +``` + +## 预期结果 + +```txt +[Invoke Tool: skill_load(...)] +[Invoke Tool: skill_exec({ + "skill": "interactive-report", + "command": "python3 scripts/create_report.py", + "stdin": "release-1.2.0\n2\n", + "output_files": ["out/report.txt"], + "save_as_artifacts": true +})] +[Tool Result: { + "status": "exited", + "exit_code": 0, + "result": { + "output_files": [{ + "name": "out/report.txt", + "content": "Release: release-1.2.0\nMode: detailed\n..." + }], + "artifact_files": [{ + "name": "skill-exec-demo/out/report.txt", + "version": 0 + }] + } +}] +``` + +验证通过需要同时满足: + +- `status=exited` 且 `exit_code=0`。 +- `output_files` 包含非空的 `out/report.txt`。 +- `artifact_files` 包含 `skill-exec-demo/out/report.txt`。 + +## 适用场景建议 + +- 交互式 CLI、安装向导、选择菜单等需要 stdin/TTY 的 Skill。 +- 长时间运行、需要分段输出或最终收集产物的 Skill。 diff --git a/examples/skills_with_exec_tool/agent/__init__.py b/examples/skills_with_exec_tool/agent/__init__.py new file mode 100644 index 000000000..bc6e483f9 --- /dev/null +++ b/examples/skills_with_exec_tool/agent/__init__.py @@ -0,0 +1,5 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. diff --git a/examples/skills_with_exec_tool/agent/agent.py b/examples/skills_with_exec_tool/agent/agent.py new file mode 100644 index 000000000..06d640aff --- /dev/null +++ b/examples/skills_with_exec_tool/agent/agent.py @@ -0,0 +1,38 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Agent configured for the interactive skill execution example.""" + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import OpenAIModel + +from .config import get_model_config +from .prompts import INSTRUCTION +from .tools import create_skill_tool_set + + +def _create_model() -> LLMModel: + """Create the configured model.""" + api_key, url, model_name = get_model_config() + model = OpenAIModel(model_name=model_name, api_key=api_key, base_url=url) + return model + + +def create_agent() -> LlmAgent: + """Create an agent with the skill execution toolset.""" + skill_tool_set, skill_repository = create_skill_tool_set() + + return LlmAgent( + name="skill_exec_agent", + description="An assistant demonstrating interactive Agent Skill execution.", + model=_create_model(), + instruction=INSTRUCTION, + tools=[skill_tool_set], + skill_repository=skill_repository, + ) + + +root_agent = create_agent() diff --git a/examples/skills_with_exec_tool/agent/config.py b/examples/skills_with_exec_tool/agent/config.py new file mode 100644 index 000000000..db0d491b8 --- /dev/null +++ b/examples/skills_with_exec_tool/agent/config.py @@ -0,0 +1,19 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +""" Agent config module""" + +import os + + +def get_model_config() -> tuple[str, str, str]: + """Get model config from environment variables""" + api_key = os.getenv('TRPC_AGENT_API_KEY', '') + url = os.getenv('TRPC_AGENT_BASE_URL', '') + model_name = os.getenv('TRPC_AGENT_MODEL_NAME', '') + if not api_key or not url or not model_name: + raise ValueError('''TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL, + and TRPC_AGENT_MODEL_NAME must be set in environment variables''') + return api_key, url, model_name diff --git a/examples/skills_with_exec_tool/agent/prompts.py b/examples/skills_with_exec_tool/agent/prompts.py new file mode 100644 index 000000000..df911e90a --- /dev/null +++ b/examples/skills_with_exec_tool/agent/prompts.py @@ -0,0 +1,23 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Instructions for the skill execution example.""" + +INSTRUCTION = """ +You demonstrate interactive Agent Skill execution. + +There is one skill: interactive-report. +Always call skill_load before executing it. +Use skill_exec, never skill_run, for the demonstration. +Pass the requested stdin, output_files, save_as_artifacts, and artifact_prefix +arguments unchanged. After execution, report: + +- process status and exit code +- collected output_files, including report content and workspace ref +- persisted artifact_files + +Do not replace the interactive script with shell redirection or another +command. +""" diff --git a/examples/skills_with_exec_tool/agent/tools.py b/examples/skills_with_exec_tool/agent/tools.py new file mode 100644 index 000000000..57bb14cef --- /dev/null +++ b/examples/skills_with_exec_tool/agent/tools.py @@ -0,0 +1,40 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Build the skill toolset used by the example.""" + +import os +from pathlib import Path + +from trpc_agent_sdk.code_executors import create_local_workspace_runtime +from trpc_agent_sdk.skills import ENV_SKILLS_ROOT +from trpc_agent_sdk.skills import SkillToolSet +from trpc_agent_sdk.skills import create_default_skill_repository +from trpc_agent_sdk.skills.tools import LinkSkillStager + + +def _get_skill_paths() -> str: + """Get the skill paths.""" + skills_root = os.getenv(ENV_SKILLS_ROOT) + if skills_root: + return skills_root + current_path = Path(__file__).parent + return str(current_path.parent / "skills") + + +def create_skill_tool_set(): + """Create a local skill repository with interactive execution enabled.""" + workspace_runtime = create_local_workspace_runtime() + skill_paths = _get_skill_paths() + repository = create_default_skill_repository( + skill_paths, + workspace_runtime=workspace_runtime, + use_cached_repository=True, + ) + skill_toolset = SkillToolSet( + repository=repository, + skill_stager=LinkSkillStager(), + ) + return skill_toolset, repository diff --git a/examples/skills_with_exec_tool/run_agent.py b/examples/skills_with_exec_tool/run_agent.py new file mode 100644 index 000000000..4a0d447b7 --- /dev/null +++ b/examples/skills_with_exec_tool/run_agent.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Demonstrate interactive skill execution and output collection.""" + +import asyncio +import json +import uuid + +from dotenv import load_dotenv +from trpc_agent_sdk.artifacts import InMemoryArtifactService +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +load_dotenv() + + +async def run_skill_exec_demo() -> None: + """Run one interactive skill and collect its output artifact.""" + from agent.agent import root_agent + + session_service = InMemorySessionService() + runner = Runner( + app_name="skill_exec_agent_demo", + agent=root_agent, + session_service=session_service, + artifact_service=InMemoryArtifactService(), + ) + session_id = str(uuid.uuid4()) + query = """ +Use only the interactive-report skill for this task. + +1. Load the skill documentation with skill_load. +2. Call skill_exec, not skill_run, with exactly these important arguments: + - skill: interactive-report + - command: python3 scripts/create_report.py + - stdin: release-1.2.0\\n2\\n + - yield_time_ms: 3000 + - output_files: ["out/report.txt"] + - save_as_artifacts: true + - artifact_prefix: "skill-exec-demo/" +3. Report the collected output_files and artifact_files from the final + skill_exec result. +""" + + print(f"Session ID: {session_id}") + print(f"User: {query}") + print("Assistant: ", end="", flush=True) + try: + async for event in runner.run_async( + user_id="demo_user", + session_id=session_id, + new_message=Content(parts=[Part.from_text(text=query)]), + ): + if not event.content or not event.content.parts: + continue + + if event.partial: + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + continue + + for part in event.content.parts: + if part.thought: + continue + if part.function_call: + args = json.dumps(part.function_call.args, ensure_ascii=False) + print(f"\n[Invoke Tool: {part.function_call.name}({args})]") + elif part.function_response: + response = json.dumps( + part.function_response.response, + ensure_ascii=False, + default=str, + ) + print(f"[Tool Result: {response}]") + print() + finally: + await runner.close() + + +if __name__ == "__main__": + asyncio.run(run_skill_exec_demo()) diff --git a/examples/skills_with_exec_tool/skills/interactive-report/SKILL.md b/examples/skills_with_exec_tool/skills/interactive-report/SKILL.md new file mode 100644 index 000000000..50991e209 --- /dev/null +++ b/examples/skills_with_exec_tool/skills/interactive-report/SKILL.md @@ -0,0 +1,33 @@ +--- +name: interactive-report +description: Generate a release report through an interactive command. +--- + +# Interactive release report + +Use this skill to demonstrate `skill_exec` stdin handling and output +collection. + +Run: + +```bash +python3 scripts/create_report.py +``` + +The program asks for: + +1. A release name. +2. Report mode `1` (compact) or `2` (detailed). + +Use `skill_exec` with newline-separated initial `stdin` when the answers are +already known. Collect the generated file with: + +```json +{ + "output_files": ["out/report.txt"], + "save_as_artifacts": true, + "artifact_prefix": "skill-exec-demo/" +} +``` + +Do not use `skill_run` for this demonstration. diff --git a/examples/skills_with_exec_tool/skills/interactive-report/scripts/create_report.py b/examples/skills_with_exec_tool/skills/interactive-report/scripts/create_report.py new file mode 100644 index 000000000..47783ec4e --- /dev/null +++ b/examples/skills_with_exec_tool/skills/interactive-report/scripts/create_report.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Interactively create a small release report.""" + +from pathlib import Path + + +def _read(prompt: str) -> str: + print(prompt, end="", flush=True) + return input().strip() + + +def main() -> None: + release_name = _read("Release name: ") + mode = _read("Report mode (1=compact, 2=detailed): ") + if not release_name: + raise ValueError("release name is required") + if mode not in {"1", "2"}: + raise ValueError("report mode must be 1 or 2") + + lines = [ + f"Release: {release_name}", + f"Mode: {'detailed' if mode == '2' else 'compact'}", + "Generated by: skill_exec", + ] + if mode == "2": + lines.extend([ + "Checks:", + "- interactive stdin received", + "- output file generated", + "- artifact collection requested", + ]) + + output = Path("out/report.txt") + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"Created {output}") + + +if __name__ == "__main__": + main() diff --git a/tests/skills/tools/test_skill_exec.py b/tests/skills/tools/test_skill_exec.py index 1593e5d95..8aaf0cdbd 100644 --- a/tests/skills/tools/test_skill_exec.py +++ b/tests/skills/tools/test_skill_exec.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock import pytest +from trpc_agent_sdk.code_executors import BaseWorkspaceRuntime from trpc_agent_sdk.code_executors import DEFAULT_EXEC_YIELD_MS from trpc_agent_sdk.code_executors import DEFAULT_IO_YIELD_MS from trpc_agent_sdk.code_executors import DEFAULT_POLL_LINES @@ -17,10 +18,13 @@ 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 _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 from trpc_agent_sdk.skills.tools._skill_exec import _has_selection_items from trpc_agent_sdk.skills.tools._skill_exec import _last_non_empty_line +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 def _make_exec_tool() -> SkillExecTool: @@ -107,3 +111,58 @@ async def test_close_session(self): sess.proc.close = AsyncMock() await _close_session(sess) sess.proc.close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_collect_final_result_passes_workspace_runtime_and_returns_files(): + ctx = MagicMock() + ws = MagicMock() + workspace_runtime = MagicMock(spec=BaseWorkspaceRuntime) + output_file = SkillRunFile( + name="result.txt", + content="done", + mime_type="text/plain", + size_bytes=4, + ) + + async def prepare_outputs(got_ctx, got_ws, got_runtime, input_data): + assert got_ctx is ctx + assert got_ws is ws + assert got_runtime is workspace_runtime + assert input_data.output_files == ["result.txt"] + return [output_file], None + + run_tool = MagicMock() + run_tool._prepare_outputs = prepare_outputs + run_tool._attach_artifacts_if_requested = AsyncMock() + run_tool._merge_manifest_artifact_refs = MagicMock() + + proc = MagicMock() + proc.run_result = AsyncMock(return_value=MagicMock( + stdout="command completed", + stderr="", + exit_code=0, + ), ) + runner = MagicMock() + runner.start_program = AsyncMock(return_value=proc) + inputs = ExecInput( + skill="test", + command="echo done > result.txt", + output_files=["result.txt"], + ) + exec_session = await _start_session( + runner=runner, + tool_context=ctx, + inputs=inputs, + ws=ws, + workspace_runtime=workspace_runtime, + rel_cwd="skills/test", + env={}, + ) + + result = await _collect_final_result(ctx, exec_session, run_tool) + + assert result is not None + assert result.output_files == [output_file] + assert result.primary_output == output_file + assert exec_session.finalized is True diff --git a/trpc_agent_sdk/skills/tools/_skill_exec.py b/trpc_agent_sdk/skills/tools/_skill_exec.py index b093088b8..d941dfac0 100644 --- a/trpc_agent_sdk/skills/tools/_skill_exec.py +++ b/trpc_agent_sdk/skills/tools/_skill_exec.py @@ -52,6 +52,7 @@ from pydantic import Field from trpc_agent_sdk.code_executors import BaseProgramRunner from trpc_agent_sdk.code_executors import BaseProgramSession +from trpc_agent_sdk.code_executors import BaseWorkspaceRuntime from trpc_agent_sdk.code_executors import DEFAULT_EXEC_YIELD_MS from trpc_agent_sdk.code_executors import DEFAULT_IO_YIELD_MS from trpc_agent_sdk.code_executors import DEFAULT_SESSION_KILL_SEC @@ -216,6 +217,7 @@ class _ExecSession: proc: BaseProgramSession ws: WorkspaceInfo + workspace_runtime: BaseWorkspaceRuntime in_data: ExecInput # Final state @@ -458,6 +460,7 @@ async def _run_async_impl( tool_context=tool_context, inputs=inputs, ws=ws, + workspace_runtime=workspace_runtime, rel_cwd=rel_cwd, env=merged_env, ) @@ -730,6 +733,7 @@ async def _start_session( tool_context: InvocationContext, inputs: ExecInput, ws: WorkspaceInfo, + workspace_runtime: BaseWorkspaceRuntime, rel_cwd: str, env: dict[str, str], ) -> _ExecSession: @@ -744,7 +748,12 @@ async def _start_session( tty=inputs.tty, ) proc = await runner.start_program(tool_context, ws, spec) - return _ExecSession(proc=proc, ws=ws, in_data=inputs) + return _ExecSession( + proc=proc, + ws=ws, + workspace_runtime=workspace_runtime, + in_data=inputs, + ) async def _write_stdin(exec_session: _ExecSession, chars: str, submit: bool) -> None: @@ -779,7 +788,12 @@ async def _collect_final_result( outputs=in_data.outputs, ) try: - files, manifest = await run_tool._prepare_outputs(ctx, exec_session.ws, fake_run_input) + files, manifest = await run_tool._prepare_outputs( + ctx, + exec_session.ws, + exec_session.workspace_runtime, + fake_run_input, + ) except Exception as ex: # pylint: disable=broad-except logger.warning("skill_exec: collect outputs failed: %s", ex) files, manifest = [], None