diff --git a/.github/workflows/go-toolchain-update.yml b/.github/workflows/go-toolchain-update.yml new file mode 100644 index 000000000..4b34b84fc --- /dev/null +++ b/.github/workflows/go-toolchain-update.yml @@ -0,0 +1,133 @@ +name: Go Toolchain Update + +on: + schedule: + # Align with Dependabot weekly checks: Monday 00:00 UTC (08:00 Asia/Shanghai). + - cron: "0 0 * * 1" + +permissions: + contents: read + +jobs: + update-go-toolchain: + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Check Go toolchain baseline + id: versions + run: | + current="$(python3 hack/go-toolchain.py current)" + latest="$(python3 hack/go-toolchain.py latest)" + + echo "current=${current}" >> "$GITHUB_OUTPUT" + echo "latest=${latest}" >> "$GITHUB_OUTPUT" + + if [ "$current" = "$latest" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Go toolchain already matches latest stable Go ${latest}." + exit 0 + fi + + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Go toolchain update needed: ${current} -> ${latest}." + + - name: Update Go toolchain baseline + if: steps.versions.outputs.changed == 'true' + run: | + python3 hack/go-toolchain.py update --version "${{ steps.versions.outputs.latest }}" + + - name: Set up Go + if: steps.versions.outputs.changed == 'true' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Verify Go toolchain baseline + if: steps.versions.outputs.changed == 'true' + run: | + go mod tidy + python3 hack/go-toolchain.py verify --check-latest --require-latest + git diff --check + git diff --exit-code -- .github/workflows + + - name: Create or update pull request + if: steps.versions.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + TARGET_GO_VERSION: ${{ steps.versions.outputs.latest }} + run: | + branch="chore/go-toolchain-${TARGET_GO_VERSION//./}" + title="chore: update Go toolchain to ${TARGET_GO_VERSION}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin "+refs/heads/${branch}:refs/remotes/origin/${branch}" || true + git switch -C "$branch" + git add go.mod go.sum docker/Dockerfile docker/Dockerfile.router docker/Dockerfile.picod + + if git diff --cached --quiet; then + echo "No Go toolchain changes remain after update." + exit 0 + fi + + git commit -s -m "$title" + git push --force-with-lease="refs/heads/${branch}" origin "$branch:$branch" + + cat > /tmp/go-toolchain-pr-body.md < str: + return path.read_text(encoding="utf-8") + + +def write_if_changed(path: Path, content: str, changed: list[Path]) -> None: + old = read_text(path) + if old == content: + return + path.write_text(content, encoding="utf-8") + changed.append(path) + + +def go_mod_path(repo: Path) -> Path: + return repo / "go.mod" + + +def read_go_version(repo: Path) -> str: + content = read_text(go_mod_path(repo)) + match = GO_DIRECTIVE_RE.search(content) + if not match: + raise ValueError("go.mod does not contain a parseable go directive") + return match.group(1) + + +def read_toolchain_version(repo: Path) -> str | None: + content = read_text(go_mod_path(repo)) + match = TOOLCHAIN_RE.search(content) + return match.group(1) if match else None + + +def latest_stable_go() -> str: + with urllib.request.urlopen("https://go.dev/dl/?mode=json", timeout=30) as response: + releases = json.load(response) + + for release in releases: + if release.get("stable"): + version = str(release["version"]) + return version[2:] if version.startswith("go") else version + + raise ValueError("go.dev did not return a stable Go release") + + +def validate_version(version: str) -> None: + if not VERSION_RE.match(version): + raise ValueError(f"invalid Go version: {version!r}") + + +def update_go_mod(repo: Path, version: str, changed: list[Path]) -> None: + path = go_mod_path(repo) + content = read_text(path) + + if not GO_DIRECTIVE_RE.search(content): + raise ValueError("go.mod does not contain a parseable go directive") + + content = GO_DIRECTIVE_RE.sub(f"go {version}", content, count=1) + content = TOOLCHAIN_LINE_RE.sub("", content) + content = re.sub(r"\n{3,}", "\n\n", content) + write_if_changed(path, content, changed) + + +def update_dockerfiles(repo: Path, version: str, changed: list[Path]) -> None: + docker_dir = repo / "docker" + if not docker_dir.is_dir(): + raise ValueError(f"missing docker directory: {docker_dir}") + + found = False + for path in sorted(docker_dir.glob("Dockerfile*")): + content = read_text(path) + + def replace(match: re.Match[str]) -> str: + nonlocal found + found = True + prefix, _old_version, suffix = match.groups() + return f"{prefix}{version}{suffix}" + + write_if_changed(path, GOLANG_IMAGE_RE.sub(replace, content), changed) + + if not found: + raise ValueError("no golang builder images found under docker/Dockerfile*") + + +def update_repo(repo: Path, version: str) -> list[Path]: + validate_version(version) + changed: list[Path] = [] + update_go_mod(repo, version, changed) + update_dockerfiles(repo, version, changed) + return changed + + +def verify_dockerfiles(repo: Path, go_version: str) -> list[str]: + errors: list[str] = [] + docker_dir = repo / "docker" + found = [] + + for path in sorted(docker_dir.glob("Dockerfile*")): + content = read_text(path) + for match in GOLANG_IMAGE_RE.finditer(content): + version, suffix = match.group(2), match.group(3) + rel = path.relative_to(repo) + found.append(rel) + print(f"Docker builder: {rel}: golang:{version}{suffix}") + if version != go_version: + errors.append(f"{rel} uses golang:{version}{suffix}, expected Go {go_version}") + + if not found: + errors.append("no golang builder images found under docker/Dockerfile*") + + return errors + + +def verify_workflows(repo: Path) -> list[str]: + errors: list[str] = [] + workflows = repo / ".github" / "workflows" + + workflow_files = list(workflows.glob("*.yml")) + list(workflows.glob("*.yaml")) + for path in sorted(workflow_files): + content = read_text(path) + if not SETUP_GO_RE.search(content): + continue + + rel = path.relative_to(repo) + has_file = bool(GO_VERSION_FILE_RE.search(content)) + has_inline = bool(GO_VERSION_RE.search(content)) + print(f"setup-go workflow: {rel}: go-version-file={has_file} inline-go-version={has_inline}") + + if not has_file: + errors.append(f"{rel} uses actions/setup-go without go-version-file: go.mod") + if has_inline: + errors.append(f"{rel} has inline go-version; prefer go-version-file: go.mod") + + return errors + + +def verify_repo(repo: Path, check_latest: bool, require_latest: bool) -> int: + errors: list[str] = [] + go_version = read_go_version(repo) + toolchain_version = read_toolchain_version(repo) + + print(f"go.mod go directive: {go_version}") + if toolchain_version: + print(f"go.mod toolchain directive: {toolchain_version}") + if toolchain_version != go_version: + errors.append(f"go.mod toolchain {toolchain_version} differs from go directive {go_version}") + else: + print("go.mod toolchain directive: ") + + errors.extend(verify_dockerfiles(repo, go_version)) + errors.extend(verify_workflows(repo)) + + if check_latest or require_latest: + latest = latest_stable_go() + print(f"latest stable Go release: {latest}") + if latest != go_version: + message = f"project Go {go_version} differs from latest stable Go {latest}" + if require_latest: + errors.append(message) + else: + print(f"NOTICE: {message}") + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print("Go toolchain alignment: OK") + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", default=".", help="Repository root. Defaults to current directory.") + + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("current", help="Print the current go.mod Go directive.") + subparsers.add_parser("latest", help="Print the latest stable Go release from go.dev.") + + update = subparsers.add_parser("update", help="Update go.mod and Docker builder image tags.") + update.add_argument("--version", help="Target Go version without the leading 'go'.") + update.add_argument("--latest", action="store_true", help="Use the latest stable Go release from go.dev.") + + verify = subparsers.add_parser("verify", help="Verify Go toolchain baseline alignment.") + verify.add_argument("--check-latest", action="store_true", help="Report the latest stable Go version.") + verify.add_argument("--require-latest", action="store_true", help="Fail if go.mod is behind latest stable Go.") + + return parser.parse_args() + + +def main() -> int: + args = parse_args() + repo = Path(args.repo_root).resolve() + + try: + if args.command == "current": + print(read_go_version(repo)) + return 0 + + if args.command == "latest": + print(latest_stable_go()) + return 0 + + if args.command == "update": + if args.latest == bool(args.version): + raise ValueError("choose exactly one of --latest or --version") + + version = latest_stable_go() if args.latest else args.version + assert version is not None + changed = update_repo(repo, version) + print(f"target Go version: {version}") + if changed: + print("updated files:") + for path in changed: + print(f"- {path.relative_to(repo)}") + else: + print("no files changed") + return 0 + + if args.command == "verify": + return verify_repo(repo, args.check_latest, args.require_latest) + + except Exception as exc: # noqa: BLE001 - CLI should print compact errors. + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + raise AssertionError(f"unhandled command: {args.command}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/code-interpreter-mcp/agentcube_code_interpreter_mcp/server.py b/integrations/code-interpreter-mcp/agentcube_code_interpreter_mcp/server.py index b66d6fb8b..e2d69c35b 100644 --- a/integrations/code-interpreter-mcp/agentcube_code_interpreter_mcp/server.py +++ b/integrations/code-interpreter-mcp/agentcube_code_interpreter_mcp/server.py @@ -15,6 +15,7 @@ import requests from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError from pydantic import Field # In-process cache of live CodeInterpreterClient instances for session_reuse. @@ -178,6 +179,7 @@ def create_mcp_server( instructions=instr, ) CodeInterpreterClient = _import_client() + from agentcube.exceptions import CommandExecutionError @app.tool(structured_output=False) def run_code( @@ -214,7 +216,10 @@ def run_code( cfg = _load_server() def _op(c: Any) -> Any: - return c.run_code(language, code, timeout=timeout_seconds) + try: + return c.run_code(language, code, timeout=timeout_seconds) + except CommandExecutionError as exc: + raise ToolError(str(exc)) from exc out, meta, sid = _call_with_session_recovery( session_id, session_reuse, cfg, CodeInterpreterClient, _op @@ -249,7 +254,10 @@ def execute_command( cfg = _load_server() def _op(c: Any) -> Any: - return c.execute_command(command, timeout=timeout_seconds) + try: + return c.execute_command(command, timeout=timeout_seconds) + except CommandExecutionError as exc: + raise ToolError(str(exc)) from exc out, meta, sid = _call_with_session_recovery( session_id, session_reuse, cfg, CodeInterpreterClient, _op diff --git a/integrations/code-interpreter-mcp/tests/test_server.py b/integrations/code-interpreter-mcp/tests/test_server.py new file mode 100644 index 000000000..3f3bdc14b --- /dev/null +++ b/integrations/code-interpreter-mcp/tests/test_server.py @@ -0,0 +1,78 @@ +# Copyright The Volcano Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +import asyncio +import os +import unittest +from unittest.mock import patch + +from agentcube.exceptions import CommandExecutionError +from mcp import Client + +from agentcube_code_interpreter_mcp.server import create_mcp_server + + +class _FakeClient: + error = None + + def __init__(self, **_kwargs): + self.session_id = "test-session" + + def run_code(self, _language, _code, timeout=None): + del timeout + raise self.error + + def execute_command(self, _command, timeout=None): + del timeout + raise self.error + + def stop(self): + self.session_id = None + + +class TestCommandToolErrors(unittest.TestCase): + def _call_tool(self, name, arguments, error): + async def call(): + _FakeClient.error = error + env = { + "ROUTER_URL": "http://router.test", + "WORKLOAD_MANAGER_URL": "http://manager.test", + } + with ( + patch.dict(os.environ, env), + patch( + "agentcube_code_interpreter_mcp.server._import_client", + return_value=_FakeClient, + ), + ): + server = create_mcp_server() + async with Client(server) as client: + return await client.call_tool(name, arguments) + + return asyncio.run(call()) + + def test_run_code_exposes_expected_execution_error(self): + result = self._call_tool( + "run_code", + {"language": "python", "code": "print(x)"}, + CommandExecutionError(1, "NameError: name 'x' is not defined"), + ) + + self.assertTrue(result.is_error) + self.assertIn("NameError: name 'x' is not defined", result.content[0].text) + + def test_execute_command_exposes_expected_execution_error(self): + result = self._call_tool( + "execute_command", + {"command": "exit 7"}, + CommandExecutionError(7, "command failed"), + ) + + self.assertTrue(result.is_error) + self.assertIn("Command failed (exit 7): command failed", result.content[0].text) + + +if __name__ == "__main__": + unittest.main()