diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml index adeb7b300..540e33d32 100644 --- a/.github/workflows/harness-sidecar-release-gate.yaml +++ b/.github/workflows/harness-sidecar-release-gate.yaml @@ -10,15 +10,32 @@ on: - 'tests/cli/test_managed_sidecar_source.py' - 'tests/cli/test_legacy_runtime_recovery.py' - 'tests/cli/test_generated_agent_backend_codegen_extended.py' + - 'tests/cli/test_generated_agent_sidecar_runtime.py' - 'tests/cli/test_generated_agent_mcp.py' - 'tests/cli/test_studio_rbac.py' + - 'tests/cli/test_studio_release.py' + - 'tests/cli/test_studio_update.py' + - 'tests/cli/test_studio_startup_imports.py' + - 'tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py' + - 'tests/frontend/server/test_runtime_iam.py' - 'tests/extensions/harness/**' - 'tests/integrations/agentkit/test_app.py' + - 'tests/test_harness_sidecar_release_gate.py' + - 'tests/test_cloud.py' + - 'tests/test_studio_release_server.py' + - 'tests/test_studio_release_workflow.py' - 'veadk/cli/generated_agent_codegen.py' - 'veadk/cli/generated_agent_mcp.py' + - 'veadk/cli/generated_agent_sidecar_runtime.py' - 'veadk/cli/cli_frontend.py' + - 'frontend/server/runtime_iam.py' + - 'veadk/cli/agentkit_cli.py' - 'veadk/cli/managed_sidecar_source.py' - 'veadk/cli/legacy_runtime_recovery.py' + - 'veadk/cli/studio_vpc_network.py' + - 'veadk/cli/studio_release.py' + - 'veadk/cli/studio_self_update.py' + - 'veadk/integrations/ve_faas/ve_faas.py' - 'veadk/extensions/harness/**' - 'veadk/integrations/agentkit/app.py' - 'pyproject.toml' @@ -30,15 +47,32 @@ on: - 'tests/cli/test_managed_sidecar_source.py' - 'tests/cli/test_legacy_runtime_recovery.py' - 'tests/cli/test_generated_agent_backend_codegen_extended.py' + - 'tests/cli/test_generated_agent_sidecar_runtime.py' - 'tests/cli/test_generated_agent_mcp.py' - 'tests/cli/test_studio_rbac.py' + - 'tests/cli/test_studio_release.py' + - 'tests/cli/test_studio_update.py' + - 'tests/cli/test_studio_startup_imports.py' + - 'tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py' + - 'tests/frontend/server/test_runtime_iam.py' - 'tests/extensions/harness/**' - 'tests/integrations/agentkit/test_app.py' + - 'tests/test_harness_sidecar_release_gate.py' + - 'tests/test_cloud.py' + - 'tests/test_studio_release_server.py' + - 'tests/test_studio_release_workflow.py' - 'veadk/cli/generated_agent_codegen.py' - 'veadk/cli/generated_agent_mcp.py' + - 'veadk/cli/generated_agent_sidecar_runtime.py' - 'veadk/cli/cli_frontend.py' + - 'frontend/server/runtime_iam.py' + - 'veadk/cli/agentkit_cli.py' - 'veadk/cli/managed_sidecar_source.py' - 'veadk/cli/legacy_runtime_recovery.py' + - 'veadk/cli/studio_vpc_network.py' + - 'veadk/cli/studio_release.py' + - 'veadk/cli/studio_self_update.py' + - 'veadk/integrations/ve_faas/ve_faas.py' - 'veadk/extensions/harness/**' - 'veadk/integrations/agentkit/app.py' - 'pyproject.toml' @@ -47,9 +81,10 @@ permissions: contents: read jobs: - python: + backend-gate: + name: Backend gate runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 3 steps: - uses: actions/checkout@v4 @@ -68,7 +103,7 @@ jobs: shell: bash run: | set -euo pipefail - checks=(coverage lifecycle credentials) + checks=(coverage lifecycle credentials release) pids=() ( python -m pytest -q \ @@ -76,7 +111,10 @@ jobs: tests/extensions/harness/test_sidecar_public_api.py \ tests/extensions/harness/test_extension.py \ tests/cli/test_generated_agent_harness_sidecar.py \ + tests/cli/test_generated_agent_sidecar_runtime.py \ tests/cli/test_managed_sidecar_source.py \ + tests/cli/test_studio_startup_imports.py \ + tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py \ tests/integrations/agentkit/test_app.py \ tests/cli/test_studio_rbac.py::test_sidecar_deployment_uses_agentkit_cli_structured_release \ tests/cli/test_generated_agent_backend_codegen_extended.py::test_generated_agent_sidecar_debug_uses_runtime_apig_and_active_plan \ @@ -88,19 +126,59 @@ jobs: pids+=("$!") ( python -m pytest -q \ - tests/cli/test_legacy_runtime_recovery.py -k 'six_case_ or changed_unnamed_mcp_url' + tests/cli/test_legacy_runtime_recovery.py -k 'six_case_ or changed_unnamed_mcp_url or zero_mcp_servers_json' \ + tests/extensions/harness/sidecar_runtime/test_sidecar.py::test_zero_structured_mcp_is_valid_and_does_not_reactivate_legacy_upstream ) > "$RUNNER_TEMP/sidecar-lifecycle.log" 2>&1 & pids+=("$!") ( python -m pytest -q \ + tests/frontend/server/test_runtime_iam.py \ tests/cli/test_generated_agent_mcp.py \ tests/cli/test_generated_agent_backend_codegen_extended.py::test_generated_debug_applies_published_mcp_credential_contract_before_discovery \ + tests/cli/test_studio_rbac.py::test_deployment_status_recovers_completed_update_from_fresh_instance \ tests/cli/test_studio_rbac.py::test_new_deployment_only_updates_non_default_instance_range \ tests/cli/test_studio_rbac.py::test_sidecar_deployment_uses_agentkit_cli_structured_release \ + tests/cli/test_studio_rbac.py::test_source_preserving_disabled_sidecar_contract_filters_harness_env \ + tests/cli/test_studio_rbac.py::test_source_preserving_output_repository_migrates_only_tagless_runtimes \ + tests/cli/test_studio_rbac.py::test_source_preserving_update_ignores_browser_source_and_keeps_secrets_out_of_build \ + tests/cli/test_studio_rbac.py::test_source_preserving_legacy_ops_update_migrates_output_repository_via_sdk \ tests/cli/test_studio_rbac.py::test_application_owned_mcp_update_routes_cover_reuse_and_additions \ tests/cli/test_studio_rbac.py::test_sidecar_update_resolves_or_explicitly_reuses_stored_mcp_credentials ) > "$RUNNER_TEMP/sidecar-credentials.log" 2>&1 & pids+=("$!") + ( + python -m pytest -q \ + tests/test_studio_release_server.py::test_release_server_agentkit_cli_pin_matches_veadk \ + tests/test_studio_release_server.py::test_publisher_repairs_missing_agentkit_cli_before_manifest \ + tests/test_studio_release_server.py::test_release_server_zip_normalizes_runtime_file_permissions \ + tests/test_studio_release_server.py::test_stage_deployment_uses_frontend_service_package \ + tests/cli/test_studio_release.py::test_release_zip_normalizes_runtime_file_permissions \ + tests/test_studio_release_workflow.py::test_smoke_gate_is_fresh_amd64_and_runs_before_import_validation \ + tests/test_studio_release_workflow.py::test_smoke_gate_executes_bundle_as_low_privilege_user \ + tests/test_studio_release_workflow.py::test_smoke_gate_survives_platform_entrypoint_mode_normalization \ + tests/test_studio_release_workflow.py::test_smoke_gate_requires_unexpected_studio_exit_to_fail_closed \ + tests/cli/test_studio_update.py::test_update_application_code_bundle_merges_only_explicit_environment \ + tests/cli/test_studio_update.py::test_studio_update_preserves_custom_function_command \ + tests/cli/test_studio_update.py::test_native_python_function_uses_mode_independent_bundle_entrypoint \ + tests/cli/test_studio_release.py::test_release_entrypoint_parallel_startup_fails_closed \ + tests/auth/test_oauth2_auth.py::test_from_veidentity_uses_existing_client_secret_without_client_lookup \ + tests/cli/test_studio_rbac.py::test_runtime_veidentity_oauth_preflight_is_read_only_and_reuses_secret \ + tests/cli/test_studio_rbac.py::test_local_veidentity_oauth_preserves_auto_provisioning \ + tests/cli/test_studio_rbac.py::test_runtime_identity_and_oauth_preflights_run_in_parallel \ + tests/test_cloud.py::test_vefaas_deploy_updates_existing_application_in_place \ + tests/frontend/server/test_user_management.py::test_initialized_runtime_identity_failure_blocks_studio_startup \ + tests/frontend/server/test_user_management.py::test_directory_normalizes_credential_resolver_failure \ + tests/frontend/server/test_user_management.py::test_uninitialized_runtime_with_readable_identity_never_writes \ + tests/frontend/server/test_user_management.py::test_missing_marker_blocks_runtime_even_after_identity_was_prepared \ + tests/frontend/server/test_user_management.py::test_deployment_identity_migration_allows_read_only_runtime_start \ + tests/frontend/server/test_user_management.py::test_uninitialized_runtime_blocks_before_identity_access_or_writes \ + tests/cli/test_studio_self_update.py::test_submit_latest_uses_fixed_deployment_ids_and_sts \ + tests/cli/test_studio_self_update.py::test_self_update_identity_migration_failure_blocks_function_submit \ + tests/test_studio_release_server.py::test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version \ + tests/test_studio_release_server.py::test_tos_dependency_store_rejects_untrusted_agentkit_cli_url \ + tests/test_harness_sidecar_release_gate.py + ) > "$RUNNER_TEMP/sidecar-release.log" 2>&1 & + pids+=("$!") status=0 for index in "${!pids[@]}"; do @@ -113,9 +191,10 @@ jobs: done exit "$status" - frontend: + frontend-gate: + name: Frontend gate runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 3 steps: - uses: actions/checkout@v4 @@ -138,15 +217,18 @@ jobs: run: npm test gate: - if: always() - needs: [python, frontend] + name: Gate + if: ${{ always() }} + needs: + - backend-gate + - frontend-gate runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 1 steps: - - name: Require every Sidecar check to pass + - name: Require both parallel gates env: - PYTHON_RESULT: ${{ needs.python.result }} - FRONTEND_RESULT: ${{ needs.frontend.result }} + BACKEND_RESULT: ${{ needs.backend-gate.result }} + FRONTEND_RESULT: ${{ needs.frontend-gate.result }} run: | - test "$PYTHON_RESULT" = success + test "$BACKEND_RESULT" = success test "$FRONTEND_RESULT" = success diff --git a/.github/workflows/publish-studio-release.yaml b/.github/workflows/publish-studio-release.yaml index b1e756297..0f1398314 100644 --- a/.github/workflows/publish-studio-release.yaml +++ b/.github/workflows/publish-studio-release.yaml @@ -215,11 +215,73 @@ jobs: VOLCENGINE_SECRET_KEY: smoke run: | set -euo pipefail - package_dir="$RUNNER_TEMP/studio-release-package" - runtime_venv="$RUNNER_TEMP/studio-release-runtime" - export package_dir runtime_venv + test "$(uname -s)" = "Linux" + test "$(uname -m)" = "x86_64" + test "$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" = "3.12" + COLD_START_DEADLINE_SECONDS=60 + STUDIO_MAX_BUNDLE_BYTES=$((256 * 1024 * 1024)) + bundle=("$RUNNER_TEMP"/studio-release-output/studio-bundle-*.zip) + test "${#bundle[@]}" -eq 1 + test "$(stat -c %s "${bundle[0]}")" -lt "$STUDIO_MAX_BUNDLE_BYTES" + + permission_probe_root="$(mktemp -d)" + permission_probe="$permission_probe_root/package" + permission_probe_bin="$permission_probe_root/bin" + permission_probe_state="$permission_probe_root/state" + mkdir "$permission_probe" "$permission_probe_bin" "$permission_probe_state" + chmod 755 "$permission_probe_root" "$permission_probe" "$permission_probe_bin" + chmod 700 "$permission_probe_state" + sudo chown nobody "$permission_probe_state" + unzip -q "${bundle[0]}" -d "$permission_probe" + test "$(stat -c %a "$permission_probe/run.sh")" = "755" + test "$(stat -c %a "$permission_probe/agentkit-linux-x64.tar.gz")" = "644" + cat >"$permission_probe_bin/python3" <<'SH' + #!/bin/bash + set -eu + if [[ "$*" == *"veadk.cli.studio_companion"* ]]; then + test -r "$VEADK_STUDIO_AGENTKIT_CLI_ARCHIVE" + touch "$PERMISSION_PROBE_STATE/companion-started" + while [ ! -f "$PERMISSION_PROBE_STATE/studio-started" ]; do sleep 0.01; done + exit 0 + fi + touch "$PERMISSION_PROBE_STATE/studio-started" + while [ ! -f "$PERMISSION_PROBE_STATE/companion-started" ]; do sleep 0.01; done + SH + chmod 755 "$permission_probe_bin/python3" + direct_stderr="$permission_probe_root/direct.stderr" + if sudo -u nobody -H env \ + PATH="$permission_probe_bin:/usr/bin:/bin" \ + PERMISSION_PROBE_STATE="$permission_probe_state" \ + "$permission_probe/run.sh" 2>"$direct_stderr"; then + echo "entrypoint accepted an unexpected Studio exit" >&2 + exit 1 + fi + grep -qx "studio_process_exited_unexpectedly" "$direct_stderr" + sudo -u nobody -H rm \ + "$permission_probe_state/companion-started" \ + "$permission_probe_state/studio-started" + chmod 644 "$permission_probe/run.sh" + normalized_stderr="$permission_probe_root/normalized.stderr" + if sudo -u nobody -H env \ + PATH="$permission_probe_bin:/usr/bin:/bin" \ + PERMISSION_PROBE_STATE="$permission_probe_state" \ + bash "$permission_probe/run.sh" 2>"$normalized_stderr"; then + echo "normalized entrypoint accepted an unexpected Studio exit" >&2 + exit 1 + fi + grep -qx "studio_process_exited_unexpectedly" "$normalized_stderr" - uv run --frozen --group dev python - <<'PY' + smoke_studio() { + provider="$1" + port="$2" + package_dir="$RUNNER_TEMP/studio-release-package-${provider}" + runtime_venv="$RUNNER_TEMP/studio-release-runtime-${provider}" + log="$RUNNER_TEMP/studio-${provider}.log" + response="$RUNNER_TEMP/studio-${provider}-ui-config.json" + export package_dir + test ! -e "$package_dir" + test ! -e "$runtime_venv" + uv run --frozen --group dev python - <<'PY' import os from pathlib import Path @@ -229,37 +291,11 @@ jobs: bundle = next(output_dir.glob("studio-bundle-*.zip")) extract_studio_bundle(bundle, Path(os.environ["package_dir"])) PY - - uv venv "$runtime_venv" - ( - cd "$package_dir" - uv pip install --python "$runtime_venv/bin/python" -r requirements.txt - ) - - ( - cd "$package_dir" - "$runtime_venv/bin/python" - <<'PY' - from pathlib import Path - - import frontend - import veadk - import frontend.server.cronjobs - import frontend.service.studio_release_server - import frontend.service.studio_scheduler - - runtime_root = Path(veadk.__file__).resolve().parents[1] - assert Path(frontend.__file__).resolve().is_relative_to(runtime_root) - webui = Path(veadk.__file__).resolve().parent / "webui" / "index.html" - assert webui.is_file() - print(f"Validated installed Studio packages from {runtime_root}") - PY - ) - - smoke_studio() { - provider="$1" - port="$2" - log="$RUNNER_TEMP/studio-${provider}.log" - response="$RUNNER_TEMP/studio-${provider}-ui-config.json" + uv venv --python 3.12 "$runtime_venv" + ( + cd "$package_dir" + uv pip install --python "$runtime_venv/bin/python" -r requirements.txt + ) ( cd "$package_dir" PATH="$runtime_venv/bin:$PATH" \ @@ -269,7 +305,8 @@ jobs: ./run.sh ) >"$log" 2>&1 & pid=$! - for _ in {1..60}; do + started=$SECONDS + while (( SECONDS - started < COLD_START_DEADLINE_SECONDS )); do if ! kill -0 "$pid" 2>/dev/null; then cat "$log" return 1 @@ -298,6 +335,27 @@ jobs: smoke_studio byteplus 18877 smoke_studio volcengine 18878 + package_dir="$RUNNER_TEMP/studio-release-package-volcengine" + runtime_venv="$RUNNER_TEMP/studio-release-runtime-volcengine" + ( + cd "$package_dir" + "$runtime_venv/bin/python" - <<'PY' + from pathlib import Path + + import frontend + import veadk + import frontend.server.cronjobs + import frontend.service.studio_release_server + import frontend.service.studio_scheduler + + runtime_root = Path(veadk.__file__).resolve().parents[1] + assert Path(frontend.__file__).resolve().is_relative_to(runtime_root) + webui = Path(veadk.__file__).resolve().parent / "webui" / "index.html" + assert webui.is_file() + print(f"Validated installed Studio packages from {runtime_root}") + PY + ) + release-context: if: >- github.event_name == 'workflow_dispatch' && diff --git a/frontend/server/agent_reviews/repository.py b/frontend/server/agent_reviews/repository.py index 57f1f332b..80b56404a 100644 --- a/frontend/server/agent_reviews/repository.py +++ b/frontend/server/agent_reviews/repository.py @@ -19,9 +19,6 @@ from collections.abc import Callable, Iterator from typing import Any -from agentkit.sdk.runtime import types as sdk -from agentkit.sdk.runtime.client import AgentkitRuntimeClient - from frontend.server.agentkit_clients import create_agentkit_client from frontend.server.storage import StudioProvider @@ -38,6 +35,8 @@ def __init__( self.credentials = credentials def client(self, region: str) -> Any: + from agentkit.sdk.runtime.client import AgentkitRuntimeClient + access_key, secret_key, token = self.credentials() return create_agentkit_client( AgentkitRuntimeClient, @@ -49,11 +48,15 @@ def client(self, region: str) -> Any: ) def get(self, region: str, runtime_id: str) -> Any: + from agentkit.sdk.runtime import types as sdk + return self.client(region).get_runtime( sdk.GetRuntimeRequest.model_validate({"RuntimeId": runtime_id}) ) def list(self, region: str) -> Iterator[Any]: + from agentkit.sdk.runtime import types as sdk + client = self.client(region) token = "" seen: set[str] = set() diff --git a/frontend/server/deployment_resources.py b/frontend/server/deployment_resources.py index b35eb7f7e..822b71949 100644 --- a/frontend/server/deployment_resources.py +++ b/frontend/server/deployment_resources.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/frontend/server/environments/__init__.py b/frontend/server/environments/__init__.py index 5728c9b75..d1fddfdba 100644 --- a/frontend/server/environments/__init__.py +++ b/frontend/server/environments/__init__.py @@ -23,7 +23,6 @@ from frontend.server.agentkit_clients import create_agentkit_client from frontend.server.storage import StudioProvider, StudioStorageConfig from frontend.server.storage.tos import CredentialResolver, create_tos_client_factory -from veadk.auth.veauth.ark_veauth import get_ark_token from veadk.cli.agentkit_sandbox_region import resolve_sandbox_client_region from veadk.cli.studio_model_catalog import modelark_base_url, studio_agent_model_name from veadk.utils.cloud_provider import default_region @@ -35,6 +34,18 @@ from .tool_provisioning import AgentkitEnvironmentToolProvisioner +def _get_ark_token() -> Callable[..., str]: + from veadk.auth.veauth.ark_veauth import get_ark_token + + return get_ark_token + + +def get_ark_token(*args: Any, **kwargs: Any) -> str: + """Resolve Ark auth lazily while preserving the patchable public symbol.""" + + return _get_ark_token()(*args, **kwargs) + + def _environment_tool_model_env( *, provider: StudioProvider, diff --git a/frontend/server/environments/service.py b/frontend/server/environments/service.py index a15598ac4..8c7e7326d 100644 --- a/frontend/server/environments/service.py +++ b/frontend/server/environments/service.py @@ -29,22 +29,10 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path, PurePosixPath -from typing import Any, Literal, Protocol +from typing import TYPE_CHECKING, Any, Literal, Protocol from urllib.parse import urlsplit from uuid import uuid4 -from veadk.cli.generated_agent_codegen import ( - AgentDraft, - GeneratedFile, - GeneratedProject, - SelectedSkill, -) -from veadk.cli.generated_agent_skills import ( - SkillSpaceResolver, - materialize_selected_skills, - skill_name_from_markdown, -) - from .dockerfile import ( build_dockerfile, environment_base_image, @@ -91,6 +79,35 @@ from .resources import EnvironmentCloudGateway from .tool_provisioning import EnvironmentToolProvisioner +if TYPE_CHECKING: + from veadk.cli.generated_agent_codegen import GeneratedFile + from veadk.cli.generated_agent_skills import SkillSpaceResolver +else: + + def GeneratedFile(*args: Any, **kwargs: Any): # noqa: N802 + """Preserve the injectable file factory without loading codegen at startup.""" + from veadk.cli.generated_agent_codegen import GeneratedFile as _GeneratedFile + + return _GeneratedFile(*args, **kwargs) + + +async def materialize_selected_skills(*args: Any, **kwargs: Any) -> None: + """Load generated-skill runtime only for a real environment build.""" + from veadk.cli.generated_agent_skills import ( + materialize_selected_skills as _materialize_selected_skills, + ) + + await _materialize_selected_skills(*args, **kwargs) + + +def skill_name_from_markdown(content: str) -> str | None: + """Load generated-skill parsing only when materialized files are inspected.""" + from veadk.cli.generated_agent_skills import ( + skill_name_from_markdown as _skill_name_from_markdown, + ) + + return _skill_name_from_markdown(content) + class WorkspaceReferenceLookup(Protocol): async def workspace_names_for_environment( @@ -1135,6 +1152,15 @@ async def _materialize_environment_skills( owner_id: str, environment: EnvironmentRecord, ) -> tuple[list[GeneratedFile], EnvironmentSkillManifest]: + if not environment.selected_skills: + return [], EnvironmentSkillManifest() + + from veadk.cli.generated_agent_codegen import ( + AgentDraft, + GeneratedProject, + SelectedSkill, + ) + selected: list[SelectedSkill] = [] for item in environment.selected_skills: payload = item.model_dump(by_alias=True) @@ -1150,8 +1176,6 @@ async def _materialize_environment_skills( payload.pop("artifactId", None) skill = SelectedSkill.model_validate(payload) selected.append(skill) - if not selected: - return [], EnvironmentSkillManifest() project = GeneratedProject(name="environment", files=[]) await materialize_selected_skills( AgentDraft(name="environment", selectedSkills=selected), diff --git a/frontend/server/evaluation_automation/model_gateway.py b/frontend/server/evaluation_automation/model_gateway.py index cf368f96c..894fdfef4 100644 --- a/frontend/server/evaluation_automation/model_gateway.py +++ b/frontend/server/evaluation_automation/model_gateway.py @@ -24,8 +24,6 @@ from pydantic import BaseModel -from veadk import Agent, Runner - from .models import ( AutoEvaluationCase, AutoEvaluationOutput, @@ -115,6 +113,8 @@ async def _run( schema: type[OutputT], payload: Any, ) -> OutputT: + from veadk import Agent, Runner + agent = Agent( name=name, description="AgentKit Studio evaluation automation.", diff --git a/frontend/server/feishu_bot_setup/service.py b/frontend/server/feishu_bot_setup/service.py index 3d651fda2..0185c6786 100644 --- a/frontend/server/feishu_bot_setup/service.py +++ b/frontend/server/feishu_bot_setup/service.py @@ -141,9 +141,33 @@ def _payload( return payload -def create_feishu_bot_setup_service() -> FeishuBotSetupService: - """Create the service backed by Feishu's official app-registration flow.""" +class _LazyFeishuBotSetupProvider: + """Load QR and HTTP dependencies only when setup is first requested.""" + + def __init__(self) -> None: + self._delegate: FeishuBotSetupProvider | None = None + self._lock = threading.Lock() + + def _resolve(self) -> FeishuBotSetupProvider: + if self._delegate is not None: + return self._delegate + with self._lock: + if self._delegate is None: + from .feishu_app_registration import FeishuAppRegistrationProvider - from .feishu_app_registration import FeishuAppRegistrationProvider + self._delegate = FeishuAppRegistrationProvider() + return self._delegate - return FeishuBotSetupService(FeishuAppRegistrationProvider()) + def create(self, *, agent_name: str) -> ProviderSession: + return self._resolve().create(agent_name=agent_name) + + def poll(self, provider_id: str) -> ProviderResult: + return self._resolve().poll(provider_id) + + def cancel(self, provider_id: str) -> None: + self._resolve().cancel(provider_id) + + +def create_feishu_bot_setup_service() -> FeishuBotSetupService: + """Create the service backed by Feishu's official app-registration flow.""" + return FeishuBotSetupService(_LazyFeishuBotSetupProvider()) diff --git a/frontend/server/intelligent_development_projects/service.py b/frontend/server/intelligent_development_projects/service.py index 3f9ebd3ac..f276be30d 100644 --- a/frontend/server/intelligent_development_projects/service.py +++ b/frontend/server/intelligent_development_projects/service.py @@ -22,17 +22,14 @@ import shlex from collections.abc import Mapping from datetime import datetime, timezone +from typing import TYPE_CHECKING from uuid import uuid4 -from frontend.server.intelligent_development import DeliveryReference, release_path -from frontend.server.intelligent_development_task import IntentDecision -from frontend.server.sandbox_remote import SandboxRemoteTransport from frontend.server.source_project_limits import ( SOURCE_PROJECT_MAX_BYTES, SOURCE_PROJECT_MAX_FILES, SOURCE_PROJECT_MAX_REPORT_BYTES, ) -from veadk.cli.frontend_sandbox import SandboxSessionUnavailableError from .models import ( IntelligentDevelopmentProject, @@ -49,6 +46,21 @@ ) from .models import SourceVersionView +if TYPE_CHECKING: + from frontend.server.intelligent_development import DeliveryReference + from frontend.server.intelligent_development_task import IntentDecision + from frontend.server.sandbox_remote import SandboxRemoteTransport +else: + + def SandboxRemoteTransport(endpoint: str): # noqa: N802 + """Preserve the injectable transport factory without loading it at startup.""" + from frontend.server.sandbox_remote import ( + SandboxRemoteTransport as _SandboxRemoteTransport, + ) + + return _SandboxRemoteTransport(endpoint) + + _MAX_ARTIFACT_BYTES = SOURCE_PROJECT_MAX_BYTES _MAX_REPORT_BYTES = SOURCE_PROJECT_MAX_REPORT_BYTES logger = logging.getLogger(__name__) @@ -183,6 +195,7 @@ async def restore_base_version( from frontend.server.intelligent_development_source import ( load_intelligent_development_artifact, ) + from veadk.cli.frontend_sandbox import SandboxSessionUnavailableError from .repository import IntelligentDevelopmentVersionIntegrityError @@ -270,6 +283,8 @@ async def persist_delivery( delivery: DeliveryReference, decision: IntentDecision, ) -> tuple[IntelligentDevelopmentProject, IntelligentDevelopmentVersion]: + from frontend.server.intelligent_development import release_path + binding = await self._resolved_binding(owner_id, session_id) base_version = ( await self.repository.get_version( diff --git a/frontend/server/intelligent_development_routes.py b/frontend/server/intelligent_development_routes.py index 278453dd0..0181b46b4 100644 --- a/frontend/server/intelligent_development_routes.py +++ b/frontend/server/intelligent_development_routes.py @@ -25,11 +25,10 @@ from collections.abc import AsyncGenerator, Callable from dataclasses import replace from datetime import datetime, timezone -from typing import Any +from typing import TYPE_CHECKING, Any from uuid import uuid4 import anyio -from agentkit.toolkit.cli.sandbox.env_config import build_exec_session_envs from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, Response, StreamingResponse from starlette.types import Receive, Scope, Send @@ -56,7 +55,6 @@ project_error_detail, project_http_error, ) -from frontend.server.sandbox_remote import SandboxRemoteTransport from veadk.cli.codex_app_server import ( CodexAppServerError, CodexPermissionSettings, @@ -81,13 +79,46 @@ SandboxValidationError, mount_sandbox_routes, ) -from veadk.cli.frontend_skill_creator import _sandbox_model_config from veadk.cli.studio_model_catalog import ( provider_allows_studio_development_model, ) from veadk.utils.cloud_provider import cloud_provider_from_env from veadk.utils.logger import get_logger +if TYPE_CHECKING: + from agentkit.sdk.tools.types import EnvsItemForCreateSession + from frontend.server.sandbox_remote import SandboxRemoteTransport +else: + + def SandboxRemoteTransport(endpoint: str): # noqa: N802 + """Preserve the injectable transport factory without loading it at startup.""" + from frontend.server.sandbox_remote import ( + SandboxRemoteTransport as _SandboxRemoteTransport, + ) + + return _SandboxRemoteTransport(endpoint) + + +def build_exec_session_envs( + *args: Any, **kwargs: Any +) -> list[EnvsItemForCreateSession] | None: + """Load AgentKit Sandbox environment helpers on the first session request.""" + from agentkit.toolkit.cli.sandbox.env_config import ( + build_exec_session_envs as _build_exec_session_envs, + ) + + return _build_exec_session_envs(*args, **kwargs) + + +def _sandbox_model_config(provider: str) -> tuple[str, str]: + """Load Skill creator model configuration only for Sandbox development.""" + from veadk.cli.frontend_skill_creator import ( + _sandbox_model_config as _resolve_sandbox_model_config, + ) + + return _resolve_sandbox_model_config(provider) + + INTELLIGENT_DEVELOPMENT_PREFIX = "/web/intelligent-development" INTELLIGENT_DEVELOPMENT_TOOL_NAME = "intelligent-development" INTELLIGENT_DEVELOPMENT_AGENT_KIND = "intelligent-development" diff --git a/frontend/server/intelligent_development_source.py b/frontend/server/intelligent_development_source.py index b2d4b0d22..0d98c0cfb 100644 --- a/frontend/server/intelligent_development_source.py +++ b/frontend/server/intelligent_development_source.py @@ -24,6 +24,7 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path +from typing import TYPE_CHECKING from frontend.server.deployment_source import ( DeploymentSourceError, @@ -37,7 +38,6 @@ IntelligentDevelopmentVersionNotFound, SourceVersionProducer, ) -from frontend.server.sandbox_remote import SandboxRemoteTransport from frontend.server.source_project_limits import ( SOURCE_PROJECT_MAX_BYTES, SOURCE_PROJECT_MAX_FILES, @@ -49,6 +49,19 @@ SandboxSessionUnavailableError, ) +if TYPE_CHECKING: + from frontend.server.sandbox_remote import SandboxRemoteTransport +else: + + def SandboxRemoteTransport(endpoint: str): # noqa: N802 + """Preserve the injectable transport factory without loading it at startup.""" + from frontend.server.sandbox_remote import ( + SandboxRemoteTransport as _SandboxRemoteTransport, + ) + + return _SandboxRemoteTransport(endpoint) + + _SHA256 = re.compile(r"^[0-9a-f]{64}$") _MAX_ARTIFACT_BYTES = SOURCE_PROJECT_MAX_BYTES _MAX_EXPANDED_BYTES = SOURCE_PROJECT_MAX_BYTES diff --git a/frontend/server/intelligent_development_task.py b/frontend/server/intelligent_development_task.py index b6ba4504e..340b19a46 100644 --- a/frontend/server/intelligent_development_task.py +++ b/frontend/server/intelligent_development_task.py @@ -31,7 +31,7 @@ from pathlib import PurePosixPath import re import shlex -from typing import Literal, cast +from typing import TYPE_CHECKING, Literal, cast from uuid import uuid4 import yaml @@ -42,7 +42,18 @@ StudioCredentials, release_path, ) -from frontend.server.sandbox_remote import SandboxRemoteTransport + +if TYPE_CHECKING: + from frontend.server.sandbox_remote import SandboxRemoteTransport +else: + + def SandboxRemoteTransport(endpoint: str): # noqa: N802 + """Preserve the injectable transport factory without loading it at startup.""" + from frontend.server.sandbox_remote import ( + SandboxRemoteTransport as _SandboxRemoteTransport, + ) + + return _SandboxRemoteTransport(endpoint) CredentialResolver = Callable[[], StudioCredentials] diff --git a/frontend/server/knowledge/web_import.py b/frontend/server/knowledge/web_import.py index bfd0df0ce..8c9a701cc 100644 --- a/frontend/server/knowledge/web_import.py +++ b/frontend/server/knowledge/web_import.py @@ -26,7 +26,6 @@ from urllib.parse import SplitResult, urljoin, urlsplit, urlunsplit import httpx -from trafilatura import extract, extract_metadata MAX_REDIRECTS = 3 MAX_HTML_BYTES = 5 * 1024 * 1024 @@ -179,6 +178,8 @@ def _decode_html(html: bytes) -> str: def _extract_markdown(html: bytes, final_url: str) -> tuple[str, str]: + from trafilatura import extract, extract_metadata + decoded_html = _decode_html(html) markdown = extract( decoded_html, diff --git a/frontend/server/migration/gateway.py b/frontend/server/migration/gateway.py index ef3d66445..b1dddc3a3 100644 --- a/frontend/server/migration/gateway.py +++ b/frontend/server/migration/gateway.py @@ -23,26 +23,10 @@ from dataclasses import dataclass from typing import Any, Protocol -import requests -from agentkit.sdk.tools import types as tools_types -from agentkit.toolkit.cli.sandbox.env_config import build_exec_session_envs -from agentkit.toolkit.cli.sandbox.sandbox_client import ( - SANDBOX_FILE_DOWNLOAD_ROUTE, - build_bash_exec_url, - build_file_url, -) - from veadk.cli.agentkit_sandbox_region import ( is_agentkit_resource_not_found, sandbox_region_candidates, ) -from veadk.cli.agentkit_session_metadata import ( - build_create_session_request, - build_list_sessions_request, - call_session_client, - session_username, -) -from veadk.cli.frontend_skill_creator import _sandbox_model_config from veadk.utils.cloud_provider import cloud_provider_from_env _TOOL_ID_ENV = "SANDBOX_DEV" @@ -80,6 +64,92 @@ logger = logging.getLogger(__name__) +def _requests() -> Any: + import requests + + return requests + + +class _LazyRequests: + """Preserve the patchable module surface without importing it at startup.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_requests(), name) + + +requests = _LazyRequests() + + +def _tools_types() -> Any: + from agentkit.sdk.tools import types + + return types + + +def build_exec_session_envs(*args: Any, **kwargs: Any) -> Any: + from agentkit.toolkit.cli.sandbox.env_config import ( + build_exec_session_envs as _impl, + ) + + return _impl(*args, **kwargs) + + +def build_bash_exec_url(*args: Any, **kwargs: Any) -> str: + from agentkit.toolkit.cli.sandbox.sandbox_client import ( + build_bash_exec_url as _impl, + ) + + return _impl(*args, **kwargs) + + +def build_file_url(*args: Any, **kwargs: Any) -> str: + from agentkit.toolkit.cli.sandbox.sandbox_client import build_file_url as _impl + + return _impl(*args, **kwargs) + + +def _sandbox_file_download_route() -> str: + from agentkit.toolkit.cli.sandbox.sandbox_client import ( + SANDBOX_FILE_DOWNLOAD_ROUTE, + ) + + return SANDBOX_FILE_DOWNLOAD_ROUTE + + +def build_create_session_request(*args: Any, **kwargs: Any) -> Any: + from veadk.cli.agentkit_session_metadata import ( + build_create_session_request as _impl, + ) + + return _impl(*args, **kwargs) + + +def build_list_sessions_request(*args: Any, **kwargs: Any) -> Any: + from veadk.cli.agentkit_session_metadata import ( + build_list_sessions_request as _impl, + ) + + return _impl(*args, **kwargs) + + +def call_session_client(*args: Any, **kwargs: Any) -> Any: + from veadk.cli.agentkit_session_metadata import call_session_client as _impl + + return _impl(*args, **kwargs) + + +def session_username(*args: Any, **kwargs: Any) -> str: + from veadk.cli.agentkit_session_metadata import session_username as _impl + + return _impl(*args, **kwargs) + + +def _sandbox_model_config(*args: Any, **kwargs: Any) -> Any: + from veadk.cli.frontend_skill_creator import _sandbox_model_config as _impl + + return _impl(*args, **kwargs) + + class MigrationGatewayError(RuntimeError): """A remote dependency failure with explicit retry semantics.""" @@ -221,7 +291,7 @@ def _get_tool(self) -> tuple[Any, str]: "管理员未配置 Dev Sandbox。", status_code=503, ) - request = tools_types.GetToolRequest(ToolId=self._tool_id) + request = _tools_types().GetToolRequest(ToolId=self._tool_id) for index, region in enumerate(self._regions): try: return self._client(region).get_tool(request), region @@ -337,12 +407,12 @@ def _list_region( username=owner_id, ) else: - request = tools_types.ListSessionsRequest( + request = _tools_types().ListSessionsRequest( ToolId=self._tool_id, MaxResults=100, NextToken=next_token, Filters=[ - tools_types.FiltersItemForListSessions( + _tools_types().FiltersItemForListSessions( Name="UserSessionId", Values=[task_id], ) @@ -488,7 +558,7 @@ def _wait_for_ready_session( response = call_session_client( self._client(region), "get_session", - tools_types.GetSessionRequest( + _tools_types().GetSessionRequest( ToolId=self._tool_id, SessionId=initial.session_id, ), @@ -664,7 +734,7 @@ def get_file( endpoint = self._require_endpoint(session) try: response = requests.get( - build_file_url(endpoint, SANDBOX_FILE_DOWNLOAD_ROUTE), + build_file_url(endpoint, _sandbox_file_download_route()), params={"path": path, "change_policy": "abort"}, timeout=_READ_TIMEOUT, stream=True, @@ -724,7 +794,7 @@ def execute_bash( deadline = time.monotonic() + timeout_seconds + 30 start_marker = _BACKGROUND_START_MARKERS.get(operation, "") - def response_data(response: requests.Response) -> dict[str, object]: + def response_data(response: Any) -> dict[str, object]: if response.status_code >= 400: raise MigrationGatewayError( "MIGRATION_REMOTE_EXEC_FAILED", @@ -858,7 +928,7 @@ def background_launch_confirmed( def delete_session(self, session: MigrationSandboxSession) -> None: try: self._client(session.region).delete_session( - tools_types.DeleteSessionRequest( + _tools_types().DeleteSessionRequest( ToolId=session.tool_id, SessionId=session.session_id, ) diff --git a/frontend/server/migration/routes.py b/frontend/server/migration/routes.py index 19ae991f0..4f3bb3f1e 100644 --- a/frontend/server/migration/routes.py +++ b/frontend/server/migration/routes.py @@ -19,17 +19,12 @@ import asyncio import logging from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING, Any from fastapi import HTTPException, Query, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import Response -from frontend.server.source_projects import ( - SOURCE_PROJECT_EXCEPTIONS, - SourceProjectService, -) - from .evaluation.models import EvaluationDatasetBody, ResumeEvaluationBody from .evaluation.service import MigrationEvaluationService from .models import ( @@ -44,6 +39,9 @@ ) logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from frontend.server.source_projects import SourceProjectService + _ZIP_CONTENT_TYPES = { "application/zip", "application/x-zip-compressed", @@ -153,7 +151,7 @@ async def persist() -> dict[str, object]: "versionId": version.version_id, "message": "源码已保存到已迁移项目。", } - except (MigrationError, *SOURCE_PROJECT_EXCEPTIONS) as error: + except MigrationError as error: logger.warning( "Could not persist migration source task_id=%s error_type=%s", task_id, @@ -164,11 +162,20 @@ async def persist() -> dict[str, object]: "message": "源码暂未保存,可刷新任务重试。", "retryable": True, } - except Exception: - logger.exception( - "Unexpected migration persistence failure task_id=%s", - task_id, - ) + except Exception as error: + from frontend.server.source_projects import SOURCE_PROJECT_EXCEPTIONS + + if isinstance(error, SOURCE_PROJECT_EXCEPTIONS): + logger.warning( + "Could not persist migration source task_id=%s error_type=%s", + task_id, + type(error).__name__, + ) + else: + logger.exception( + "Unexpected migration persistence failure task_id=%s", + task_id, + ) result = { "state": "failed", "message": "源码暂未保存,可刷新任务重试。", @@ -378,12 +385,10 @@ async def watch() -> None: @app.get("/web/agent-migrations/capabilities") async def capabilities(request: Request) -> dict[str, object]: owner_resolver(request) - payload = await invoke("capabilities", service.capabilities) + payload: dict[str, object] = await invoke("capabilities", service.capabilities) if evaluation_service is not None: - payload = { - **payload, - "evaluation": evaluation_service.capabilities(), - } + payload = dict(payload) + payload["evaluation"] = evaluation_service.capabilities() return payload @app.get("/web/agent-migrations/tasks") diff --git a/frontend/server/model_catalog/client.py b/frontend/server/model_catalog/client.py index c7667e1f5..834eecc6d 100644 --- a/frontend/server/model_catalog/client.py +++ b/frontend/server/model_catalog/client.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,16 +17,19 @@ from __future__ import annotations import asyncio -from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Literal +from typing import Any from frontend.server.video.client import ArkHttpClient, ArkServiceError from veadk.utils.volcengine_sign import volcengine_signed_request -CloudCredentials = tuple[str, str, str | None] -CredentialResolver = Callable[[], CloudCredentials] -Provider = Literal["volcengine", "byteplus"] +from .protocol import ( + CloudCredentials, + CredentialResolver, + ModelCatalogError, + Provider, + SignedRequest, +) _PAGE_SIZE = 100 _MAX_PAGES = 100 @@ -56,17 +59,6 @@ class ModelCatalogProviderConfig: } -class ModelCatalogError(RuntimeError): - """A sanitized, retryable failure safe to return to a Studio client.""" - - def __init__(self, message: str, *, status_code: int = 502) -> None: - super().__init__(message) - self.status_code = status_code - - -SignedRequest = Callable[..., Any] - - class ModelCatalogClient: """Fetch activation metadata and exact data-plane model versions.""" diff --git a/frontend/server/model_catalog/protocol.py b/frontend/server/model_catalog/protocol.py new file mode 100644 index 000000000..0dc83bb45 --- /dev/null +++ b/frontend/server/model_catalog/protocol.py @@ -0,0 +1,42 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Lightweight contracts shared by model-catalog transport and services.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal + +CloudCredentials = tuple[str, str, str | None] +CredentialResolver = Callable[[], CloudCredentials] +Provider = Literal["volcengine", "byteplus"] +SignedRequest = Callable[..., Any] + + +class ModelCatalogError(RuntimeError): + """A sanitized, retryable failure safe to return to a Studio client.""" + + def __init__(self, message: str, *, status_code: int = 502) -> None: + super().__init__(message) + self.status_code = status_code + + +__all__ = [ + "CloudCredentials", + "CredentialResolver", + "ModelCatalogError", + "Provider", + "SignedRequest", +] diff --git a/frontend/server/model_catalog/routes.py b/frontend/server/model_catalog/routes.py index 0ae7e6ea9..528e65c98 100644 --- a/frontend/server/model_catalog/routes.py +++ b/frontend/server/model_catalog/routes.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,7 +18,8 @@ import os from collections.abc import Callable -from typing import Literal +from threading import Lock +from typing import TYPE_CHECKING, Any, Literal import httpx from fastapi import FastAPI, HTTPException, Query, Request, Response @@ -27,27 +28,20 @@ provider_allows_studio_development_model, ) -from frontend.server.video.client import ( - ArkHttpClient, - ArkTokenCache, - ArkTokenProvider, +from .models import ( + ModelApiKeysResponse, + ModelApiKeyValueResponse, + ModelOptionsResponse, ) - -from .client import ( - PROVIDER_CONFIGS, +from .protocol import ( CredentialResolver, - ModelApiKeyClient, - ModelCatalogClient, ModelCatalogError, Provider, SignedRequest, ) -from .models import ( - ModelApiKeysResponse, - ModelApiKeyValueResponse, - ModelOptionsResponse, -) -from .service import ModelApiKeyService, ModelCatalogService + +if TYPE_CHECKING: + from .service import ModelCatalogService _NO_STORE_HEADERS = {"Cache-Control": "no-store"} _RAW_KEY_UPSTREAM_ERROR = "无法读取所选 API Key,请检查云账号权限后重试。" @@ -61,8 +55,92 @@ def build_model_catalog_service( token_loader: Callable[..., str] | None = None, signed_request: SignedRequest | None = None, ) -> ModelCatalogService: - if provider not in PROVIDER_CONFIGS: + if provider not in {"volcengine", "byteplus"}: raise ValueError(f"Unsupported model catalog provider: {provider}") + return _LazyModelCatalogService( + provider=provider, + resolve_credentials=resolve_credentials, + http_client=http_client, + token_loader=token_loader, + signed_request=signed_request, + ) # type: ignore[return-value] + + +class _LazyModelCatalogService: + """Keep provider clients and request signing off Studio startup.""" + + def __init__( + self, + *, + provider: Provider, + resolve_credentials: CredentialResolver, + http_client: httpx.AsyncClient | None, + token_loader: Callable[..., str] | None, + signed_request: SignedRequest | None, + ) -> None: + self._provider: Provider = provider + self._resolve_credentials = resolve_credentials + self._http_client = http_client + self._token_loader = token_loader + self._signed_request = signed_request + self._service: Any | None = None + self._lock = Lock() + + def _resolve(self) -> ModelCatalogService: + if self._service is not None: + return self._service + with self._lock: + if self._service is None: + self._service = _build_model_catalog_service( + provider=self._provider, + resolve_credentials=self._resolve_credentials, + http_client=self._http_client, + token_loader=self._token_loader, + signed_request=self._signed_request, + ) + return self._service + + async def list_api_keys( + self, *, force_refresh: bool = False + ) -> ModelApiKeysResponse: + return await self._resolve().list_api_keys(force_refresh=force_refresh) + + async def resolve_raw_key(self, key_id: str) -> str: + return await self._resolve().resolve_raw_key(key_id) + + async def list_options( + self, + *, + api_key_id: str | None = None, + force_refresh: bool = False, + ) -> ModelOptionsResponse: + return await self._resolve().list_options( + api_key_id=api_key_id, + force_refresh=force_refresh, + ) + + +def _build_model_catalog_service( + *, + provider: Provider, + resolve_credentials: CredentialResolver, + http_client: httpx.AsyncClient | None, + token_loader: Callable[..., str] | None, + signed_request: SignedRequest | None, +) -> ModelCatalogService: + from frontend.server.video.client import ( + ArkHttpClient, + ArkTokenCache, + ArkTokenProvider, + ) + + from .client import ( + PROVIDER_CONFIGS, + ModelApiKeyClient, + ModelCatalogClient, + ) + from .service import ModelApiKeyService, ModelCatalogService + config = PROVIDER_CONFIGS[provider] token_cache = ( ArkTokenCache( diff --git a/frontend/server/model_catalog/service.py b/frontend/server/model_catalog/service.py index 62fa10914..29641c3d1 100644 --- a/frontend/server/model_catalog/service.py +++ b/frontend/server/model_catalog/service.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -23,13 +23,14 @@ from veadk.utils.logger import get_logger -from .client import ModelApiKeyClient, ModelCatalogClient, ModelCatalogError, Provider +from .client import ModelApiKeyClient, ModelCatalogClient from .models import ( ModelApiKeyOption, ModelApiKeysResponse, ModelOption, ModelOptionsResponse, ) +from .protocol import ModelCatalogError, Provider logger = get_logger(__name__) diff --git a/frontend/server/runtime_iam.py b/frontend/server/runtime_iam.py index 4d5751ee5..07d2dff6a 100644 --- a/frontend/server/runtime_iam.py +++ b/frontend/server/runtime_iam.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,6 +18,8 @@ import json import os +import secrets +import string import threading from typing import Any @@ -29,22 +31,21 @@ DEFAULT_RUNTIME_POLICY = "AgentKitDefaultRuntimeAccess" DEFAULT_RUNTIME_ROLE = "AgentKit_Runtime_Default_ServiceRole" +_DEFAULT_RUNTIME_ROLE_PREFIX = f"{DEFAULT_RUNTIME_ROLE}_" _ROLE_PAGE_SIZE = 100 +_ROLE_NAME_ATTEMPTS = 10 +_ROLE_SUFFIX_LENGTH = 7 +_ROLE_SUFFIX_CHARS = string.ascii_lowercase + string.digits _ROLE_LOCK = threading.Lock() -_LEGACY_RUNTIME_POLICIES = frozenset( - { - "cloudcontrolreadonlyaccess", - "agentkittosaccess", - "torchlightapifullaccess", - "llmshieldprotectsdkaccess", - "agentkittoolaccess", - "idreadonlyaccess", - "mem0readonlyaccess", - "agentkitruntimeaccess", - } +_ACCESS_DENIED_CODES = frozenset( + {"AccessDenied", "AccessDeniedException", "UnauthorizedOperation"} ) +class _PolicyEntityLookupDenied(RuntimeError): + """The caller cannot use IAM's reverse policy-to-role lookup.""" + + def _result(response: dict[str, Any]) -> dict[str, Any]: error = (response.get("ResponseMetadata") or {}).get("Error") if error: @@ -55,24 +56,11 @@ def _result(response: dict[str, Any]) -> dict[str, Any]: return result -def _role_policies(iam: Any, name: str) -> frozenset[str]: - policies = _result(iam.list_attached_role_policies({"RoleName": name})).get( - "AttachedPolicyMetadata" - ) - if not isinstance(policies, list): - raise RuntimeError("IAM returned an invalid role policy list") - return frozenset( - str(policy.get("PolicyName") or "").casefold() - for policy in policies - if policy.get("PolicyType") == "System" - ) - - -def _is_runtime_role(policies: frozenset[str]) -> bool: - return ( - DEFAULT_RUNTIME_POLICY.casefold() in policies - or _LEGACY_RUNTIME_POLICIES <= policies +def _generate_runtime_role_name() -> str: + suffix = "".join( + secrets.choice(_ROLE_SUFFIX_CHARS) for _ in range(_ROLE_SUFFIX_LENGTH) ) + return f"{_DEFAULT_RUNTIME_ROLE_PREFIX}{suffix}" def _error_code(value: object) -> str: @@ -102,7 +90,30 @@ def _get_role(iam: Any, name: str) -> dict[str, Any] | None: return role -def _find_reusable_role(iam: Any) -> str | None: +def _role_has_default_runtime_policy(iam: Any, name: str) -> bool: + policies = _result(iam.list_attached_role_policies({"RoleName": name})).get( + "AttachedPolicyMetadata" + ) + if not isinstance(policies, list): + raise RuntimeError("IAM returned an invalid role policy list") + for policy in policies: + if not isinstance(policy, dict): + raise RuntimeError("IAM returned an invalid role policy") + policy_name = policy.get("PolicyName") + policy_type = policy.get("PolicyType") + if not isinstance(policy_name, str) or not policy_name.strip(): + raise RuntimeError("IAM role policy is missing PolicyName") + if not isinstance(policy_type, str) or not policy_type.strip(): + raise RuntimeError("IAM role policy is missing PolicyType") + if ( + policy_type.casefold() == "system" + and policy_name.casefold() == DEFAULT_RUNTIME_POLICY.casefold() + ): + return True + return False + + +def _find_reusable_role_by_listing(iam: Any) -> str | None: offset = 0 while True: page = _result(iam.list_roles({"Limit": _ROLE_PAGE_SIZE, "Offset": offset})) @@ -110,17 +121,57 @@ def _find_reusable_role(iam: Any) -> str | None: total = page.get("Total") if not isinstance(roles, list) or not isinstance(total, int) or total < 0: raise RuntimeError("IAM returned an invalid role list") + if not roles and offset < total: + raise RuntimeError("IAM returned an incomplete role list") for role in roles: - name = role.get("RoleName") + name = role.get("RoleName") if isinstance(role, dict) else None if not isinstance(name, str) or not name.strip(): raise RuntimeError("IAM role is missing RoleName") - if _is_runtime_role(_role_policies(iam, name)): + if _role_has_default_runtime_policy(iam, name): return name offset += len(roles) if offset >= total: return None - if not roles: - raise RuntimeError("IAM returned an incomplete role list") + + +def _find_reusable_role_by_policy(iam: Any) -> str | None: + offset = 0 + while True: + try: + response = iam.list_entities_for_policy( + { + "PolicyName": DEFAULT_RUNTIME_POLICY, + "PolicyType": "System", + "Limit": _ROLE_PAGE_SIZE, + "Offset": offset, + } + ) + except Exception as error: + if _error_code(error) in _ACCESS_DENIED_CODES: + raise _PolicyEntityLookupDenied from error + raise + if _error_code(response) in _ACCESS_DENIED_CODES: + raise _PolicyEntityLookupDenied + page = _result(response) + roles = page.get("PolicyRoles") + total = page.get("Total") + if not isinstance(roles, list) or not isinstance(total, int) or total < 0: + raise RuntimeError("IAM returned an invalid policy entity list") + for role in roles: + name = role.get("RoleName") if isinstance(role, dict) else None + if not isinstance(name, str) or not name.strip(): + raise RuntimeError("IAM role is missing RoleName") + return name + offset += _ROLE_PAGE_SIZE + if offset >= total: + return None + + +def _find_reusable_role(iam: Any) -> str | None: + try: + return _find_reusable_role_by_policy(iam) + except _PolicyEntityLookupDenied: + return _find_reusable_role_by_listing(iam) def ensure_runtime_role( @@ -130,10 +181,11 @@ def ensure_runtime_role( session_token: str | None = None, provider: CloudProvider = DEFAULT_CLOUD_PROVIDER, ) -> str: - """Reuse a matching role, or create the shared default Runtime role. + """Reuse a minimum-policy role, or create a collision-free Runtime role. - Both current and legacy AgentKit policy layouts are recognized so evaluation - attempts never create one IAM role per temporary Runtime. + Existing roles are authoritative and never mutated. Legacy AgentKit policy + layouts are not sufficient for new Runtimes because the CLI requires the + platform-managed default Runtime policy on an explicitly selected role. """ with _ROLE_LOCK: return _ensure_runtime_role( @@ -161,21 +213,6 @@ def _ensure_runtime_role( if session_token: iam.set_session_token(session_token) - default = _get_role(iam, DEFAULT_RUNTIME_ROLE) - if default is not None: - policies = _role_policies(iam, DEFAULT_RUNTIME_ROLE) - if not _is_runtime_role(policies): - _result( - iam.attach_role_policy( - { - "RoleName": DEFAULT_RUNTIME_ROLE, - "PolicyName": DEFAULT_RUNTIME_POLICY, - "PolicyType": "System", - } - ) - ) - return DEFAULT_RUNTIME_ROLE - existing = _find_reusable_role(iam) if existing is not None: return existing @@ -197,21 +234,26 @@ def _ensure_runtime_role( } ] } - _result( - iam.create_role( - { - "RoleName": DEFAULT_RUNTIME_ROLE, - "TrustPolicyDocument": json.dumps(trust_policy), - } + for _ in range(_ROLE_NAME_ATTEMPTS): + role_name = _generate_runtime_role_name() + if _get_role(iam, role_name) is not None: + continue + _result( + iam.create_role( + { + "RoleName": role_name, + "TrustPolicyDocument": json.dumps(trust_policy), + } + ) ) - ) - _result( - iam.attach_role_policy( - { - "RoleName": DEFAULT_RUNTIME_ROLE, - "PolicyName": DEFAULT_RUNTIME_POLICY, - "PolicyType": "System", - } + _result( + iam.attach_role_policy( + { + "RoleName": role_name, + "PolicyName": DEFAULT_RUNTIME_POLICY, + "PolicyType": "System", + } + ) ) - ) - return DEFAULT_RUNTIME_ROLE + return role_name + raise RuntimeError("Unable to generate a unique AgentKit Runtime role name") diff --git a/frontend/server/sandbox_remote.py b/frontend/server/sandbox_remote.py index 3fb05c522..a6ebac4a6 100644 --- a/frontend/server/sandbox_remote.py +++ b/frontend/server/sandbox_remote.py @@ -28,6 +28,12 @@ build_file_url, ) +from frontend.server.sandbox_remote_errors import ( + SandboxRemoteError, + SandboxRemoteResponseError, + SandboxRemoteSizeError, +) + _MAX_PATH_BYTES = 512 _MAX_TRANSFER_BYTES = 20 * 1024 * 1024 _MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024 @@ -35,20 +41,6 @@ _T = TypeVar("_T") -class SandboxRemoteError(RuntimeError): - def __init__(self, message: str, *, retryable: bool = False) -> None: - super().__init__(message) - self.retryable = retryable - - -class SandboxRemoteResponseError(SandboxRemoteError): - pass - - -class SandboxRemoteSizeError(SandboxRemoteResponseError): - pass - - def _exact_file_path(value: str) -> str: if ( not isinstance(value, str) diff --git a/frontend/server/sandbox_remote_errors.py b/frontend/server/sandbox_remote_errors.py new file mode 100644 index 000000000..81ddad6f3 --- /dev/null +++ b/frontend/server/sandbox_remote_errors.py @@ -0,0 +1,31 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Lightweight Sandbox transport errors safe to import during route setup.""" + +from __future__ import annotations + + +class SandboxRemoteError(RuntimeError): + def __init__(self, message: str, *, retryable: bool = False) -> None: + super().__init__(message) + self.retryable = retryable + + +class SandboxRemoteResponseError(SandboxRemoteError): + pass + + +class SandboxRemoteSizeError(SandboxRemoteResponseError): + pass diff --git a/frontend/server/skills/devenv.py b/frontend/server/skills/devenv.py index 4ec4a4c2e..1f7b543f0 100644 --- a/frontend/server/skills/devenv.py +++ b/frontend/server/skills/devenv.py @@ -44,19 +44,6 @@ from pathlib import Path, PurePosixPath from typing import Any, Literal, cast -import requests -from agentkit.auth.errors import NetworkError -from agentkit.sdk.skills import types as skills_types -from agentkit.sdk.skills.client import AgentkitSkillsClient -from agentkit.sdk.tools import types as tools_types -from agentkit.sdk.tools.client import AgentkitToolsClient -from agentkit.toolkit.cli.sandbox.env_config import build_exec_session_envs -from agentkit.toolkit.cli.sandbox.sandbox_client import ( - SANDBOX_FILE_DOWNLOAD_ROUTE, - build_bash_exec_url, - build_exec_url, - build_file_url, -) from fastapi import HTTPException, Query, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import Response, StreamingResponse @@ -67,21 +54,8 @@ is_agentkit_resource_not_found, sandbox_region_candidates, ) -from veadk.cli.agentkit_session_metadata import ( - build_create_session_request, - build_list_sessions_request, - call_session_client, - session_display_name, - session_username, -) -from veadk.cli.frontend_skill_creator import ( - _safe_json_response, - _sandbox_model_config, - _validated_activities, -) from veadk.cli.studio_model_catalog import provider_allows_model from veadk.cli.studio_sandbox_tools import studio_sandbox_agent_model_name -from veadk.skills.skill import Skill from veadk.utils.cloud_provider import cloud_provider_from_env from veadk.utils.logger import get_logger @@ -91,6 +65,141 @@ logger = get_logger(__name__) + +def _requests() -> Any: + import requests + + return requests + + +class _LazyRequests: + """Preserve the patchable module surface without importing it at startup.""" + + def __getattr__(self, name: str) -> Any: + return getattr(_requests(), name) + + +requests = _LazyRequests() + + +def AgentkitSkillsClient(*args: Any, **kwargs: Any) -> Any: + """Construct the Skill client only when a workbench request needs it.""" + from agentkit.sdk.skills.client import AgentkitSkillsClient as _Client + + return _Client(*args, **kwargs) + + +def AgentkitToolsClient(*args: Any, **kwargs: Any) -> Any: + """Construct the Tool client only when a workbench request needs it.""" + from agentkit.sdk.tools.client import AgentkitToolsClient as _Client + + return _Client(*args, **kwargs) + + +def _skills_types() -> Any: + from agentkit.sdk.skills import types + + return types + + +def _tools_types() -> Any: + from agentkit.sdk.tools import types + + return types + + +def build_exec_session_envs(*args: Any, **kwargs: Any) -> Any: + from agentkit.toolkit.cli.sandbox.env_config import ( + build_exec_session_envs as _build_exec_session_envs, + ) + + return _build_exec_session_envs(*args, **kwargs) + + +def build_bash_exec_url(*args: Any, **kwargs: Any) -> str: + from agentkit.toolkit.cli.sandbox.sandbox_client import ( + build_bash_exec_url as _build_bash_exec_url, + ) + + return _build_bash_exec_url(*args, **kwargs) + + +def build_exec_url(*args: Any, **kwargs: Any) -> str: + from agentkit.toolkit.cli.sandbox.sandbox_client import ( + build_exec_url as _build_exec_url, + ) + + return _build_exec_url(*args, **kwargs) + + +def build_file_url(*args: Any, **kwargs: Any) -> str: + from agentkit.toolkit.cli.sandbox.sandbox_client import ( + build_file_url as _build_file_url, + ) + + return _build_file_url(*args, **kwargs) + + +def _sandbox_file_download_route() -> str: + from agentkit.toolkit.cli.sandbox.sandbox_client import ( + SANDBOX_FILE_DOWNLOAD_ROUTE, + ) + + return SANDBOX_FILE_DOWNLOAD_ROUTE + + +def _safe_json_response(*args: Any, **kwargs: Any) -> Any: + from veadk.cli.frontend_skill_creator import _safe_json_response as _impl + + return _impl(*args, **kwargs) + + +def _sandbox_model_config(*args: Any, **kwargs: Any) -> Any: + from veadk.cli.frontend_skill_creator import _sandbox_model_config as _impl + + return _impl(*args, **kwargs) + + +def _validated_activities(*args: Any, **kwargs: Any) -> Any: + from veadk.cli.frontend_skill_creator import _validated_activities as _impl + + return _impl(*args, **kwargs) + + +def build_create_session_request(*args: Any, **kwargs: Any) -> Any: + from veadk.cli.agentkit_session_metadata import ( + build_create_session_request as _impl, + ) + + return _impl(*args, **kwargs) + + +def build_list_sessions_request(*args: Any, **kwargs: Any) -> Any: + from veadk.cli.agentkit_session_metadata import ( + build_list_sessions_request as _impl, + ) + + return _impl(*args, **kwargs) + + +def call_session_client(*args: Any, **kwargs: Any) -> Any: + from veadk.cli.agentkit_session_metadata import call_session_client as _impl + + return _impl(*args, **kwargs) + + +def session_display_name(*args: Any, **kwargs: Any) -> str: + from veadk.cli.agentkit_session_metadata import session_display_name as _impl + + return _impl(*args, **kwargs) + + +def session_username(*args: Any, **kwargs: Any) -> str: + from veadk.cli.agentkit_session_metadata import session_username as _impl + + return _impl(*args, **kwargs) + + _TOOL_ID_ENV = "SANDBOX_DEV" _DEVENV_IMAGE_ENV = "VEADK_DEVENV_IMAGE" _EXPECTED_TOOL_TYPE = "DevEnv" @@ -209,6 +318,9 @@ def _exception_chain(error: BaseException) -> list[BaseException]: def _is_transient_dependency_error(error: BaseException) -> bool: + import requests + from agentkit.auth.errors import NetworkError + for current in _exception_chain(error): if isinstance( current, @@ -2235,7 +2347,7 @@ def _read_remote_artifact( for attempt in range(1, _ARTIFACT_READ_ATTEMPTS + 1): try: response = requests.get( - build_file_url(endpoint, SANDBOX_FILE_DOWNLOAD_ROUTE), + build_file_url(endpoint, _sandbox_file_download_route()), params={"path": path, "change_policy": "abort"}, timeout=(10, 120), ) @@ -2851,7 +2963,7 @@ def report(phase: str, message: str) -> None: for item in SkillVersionRepository._versions(client, effective_skill_id) } client.update_skill( - skills_types.UpdateSkillRequest( + _skills_types().UpdateSkillRequest( Id=effective_skill_id, Name=archive.name, Description=archive.description, @@ -2862,7 +2974,7 @@ def report(phase: str, message: str) -> None: ) else: created = client.create_skill( - skills_types.CreateSkillRequest( + _skills_types().CreateSkillRequest( Name=archive.name, Description=archive.description, TosUrl=tos_url, @@ -2892,10 +3004,10 @@ def report(phase: str, message: str) -> None: if body.skill_space_ids: report("publishing", "正在发布到技能空间") client.publish_skill_to_skill_space( - skills_types.PublishSkillToSkillSpaceRequest( + _skills_types().PublishSkillToSkillSpaceRequest( SkillSpaces=body.skill_space_ids, Skills=[ - skills_types.SkillBasicInfo( + _skills_types().SkillBasicInfo( SkillId=effective_skill_id, Version=version ) ], @@ -3002,7 +3114,7 @@ def _resolve_center_source( ) client = self._skills_client_factory(source.region) try: - version_request = skills_types.GetSkillVersionRequest( + version_request = _skills_types().GetSkillVersionRequest( Id=source.skill_id, SkillVersion=source.version, ) @@ -3035,7 +3147,7 @@ def _resolve_center_source( status_code=404, ) from version_error try: - info_request = skills_types.GetSkillInfoRequest( + info_request = _skills_types().GetSkillInfoRequest( SkillName=source.skill_name, SkillSpaceName=source.skill_space_name, SkillSpaceId=source.skill_space_id, @@ -3086,6 +3198,8 @@ def _archive_from_skill_response( if bucket and tos_path: from veadk.skills.materializer import _download_legacy_skill_space_skill + from veadk.skills.skill import Skill + remote = Skill( name=str( getattr(response, "skill_name", "") @@ -3170,7 +3284,7 @@ def _tool_id(self, *, required: bool = True) -> str: return value def _get_tool(self, tool_id: str) -> Any: - request = tools_types.GetToolRequest(ToolId=tool_id) + request = _tools_types().GetToolRequest(ToolId=tool_id) for index, region in enumerate(sandbox_region_candidates(self._region)): try: client = self._tools_client_factory(region) @@ -3198,12 +3312,12 @@ def _find_session(self, tool_id: str, job_id: str) -> dict[str, str]: try: client = self._tools_client_factory(region) for _page in range(100): - list_request = tools_types.ListSessionsRequest( + list_request = _tools_types().ListSessionsRequest( ToolId=tool_id, MaxResults=100, NextToken=next_token, Filters=[ - tools_types.FiltersItemForListSessions( + _tools_types().FiltersItemForListSessions( Name="UserSessionId", Values=[job_id] ) ], @@ -3400,7 +3514,7 @@ def _complete_remote_command_output( def read_output() -> bytes: response = requests.get( - build_file_url(endpoint, SANDBOX_FILE_DOWNLOAD_ROUTE), + build_file_url(endpoint, _sandbox_file_download_route()), params={"path": path, "change_policy": "abort"}, timeout=(10, 30), ) @@ -3826,7 +3940,7 @@ def _reconcile_recovery_snapshot( next_token: str | None = None seen_tokens: set[str] = set() for _page in range(100): - request = tools_types.ListSessionSnapshotsRequest( + request = _tools_types().ListSessionSnapshotsRequest( ToolId=tool_id, SessionId=session_id, UserSessionId=job_id, @@ -3906,7 +4020,7 @@ def _read_recovery_snapshot_status( job_id: str, ) -> str | None: """Read and validate the authoritative state of one checkpoint.""" - request = tools_types.GetSessionSnapshotRequest( + request = _tools_types().GetSessionSnapshotRequest( ToolId=tool_id, SnapshotId=snapshot_id, ) @@ -4011,7 +4125,7 @@ def _resume_latest_snapshot( ) return existing_session for _page in range(100): - list_request = tools_types.ListSessionSnapshotsRequest( + list_request = _tools_types().ListSessionSnapshotsRequest( ToolId=tool_id, UserSessionId=job_id, MaxResults=100, @@ -4054,7 +4168,7 @@ def _resume_latest_snapshot( ) resume_requested = True resumed = client.resume_session_from_snapshot( - tools_types.ResumeSessionFromSnapshotRequest( + _tools_types().ResumeSessionFromSnapshotRequest( ToolId=tool_id, SnapshotId=snapshot.snapshot_id, CreateNewInstance=True, @@ -4098,12 +4212,12 @@ def _reconcile_resumed_session( next_token: str | None = None seen_tokens: set[str] = set() for _page in range(100): - request = tools_types.ListSessionsRequest( + request = _tools_types().ListSessionsRequest( ToolId=tool_id, MaxResults=100, NextToken=next_token, Filters=[ - tools_types.FiltersItemForListSessions( + _tools_types().FiltersItemForListSessions( Name="UserSessionId", Values=[job_id], ) @@ -4161,7 +4275,7 @@ def _wait_for_resumed_session( ) -> dict[str, str]: deadline = time.monotonic() + 60 while True: - get_request = tools_types.GetSessionRequest( + get_request = _tools_types().GetSessionRequest( ToolId=tool_id, SessionId=session_id, ) @@ -4299,7 +4413,7 @@ def _delete_session(self, client: Any, tool_id: str, session_id: str) -> None: def delete_once() -> None: try: client.delete_session( - tools_types.DeleteSessionRequest( + _tools_types().DeleteSessionRequest( ToolId=tool_id, SessionId=session_id, ) diff --git a/frontend/server/skills/repair.py b/frontend/server/skills/repair.py index 9cc5fd8fb..09faa1c81 100644 --- a/frontend/server/skills/repair.py +++ b/frontend/server/skills/repair.py @@ -18,8 +18,6 @@ import inspect -from veadk.cli.frontend_skill_creator import _runner_source - def repair_generated_skill(root): """Repair safe frontmatter and root-name mistakes without changing behavior.""" @@ -138,6 +136,8 @@ def safe_plain_description(value): def skill_workbench_runner_source() -> str: """Inject deterministic repair into the workbench-only DevEnv runner.""" + from veadk.cli.frontend_skill_creator import _runner_source + source = _runner_source() definition_anchor = "def metadata(skill_md):" validation_anchor = """ skill_md = skill_md_path.read_text(encoding=\"utf-8\") diff --git a/frontend/server/skills/reviewer_profiles.py b/frontend/server/skills/reviewer_profiles.py index 0dbf09b03..f43003538 100644 --- a/frontend/server/skills/reviewer_profiles.py +++ b/frontend/server/skills/reviewer_profiles.py @@ -20,8 +20,6 @@ from typing import Any, TypedDict from urllib.parse import urlsplit -import volcenginesdkid as sdk - from frontend.server.user_management.directory import IdentityDirectory from frontend.server.user_management.errors import UserManagementError @@ -89,6 +87,8 @@ def resolve( self._cache.move_to_end(uid) return {**cached[1], "name": cached[1]["name"] or fallback["name"]} try: + import volcenginesdkid as sdk + # PoolUser intentionally omits profile fields; read the SDK response # through the same authenticated adapter without listing other users user = self.directory._call( diff --git a/frontend/server/skills/versions.py b/frontend/server/skills/versions.py index 6e3288964..cfa8fc148 100644 --- a/frontend/server/skills/versions.py +++ b/frontend/server/skills/versions.py @@ -18,11 +18,11 @@ import re import tempfile -import time from collections.abc import Callable from datetime import datetime, timezone from pathlib import Path from threading import Lock +from time import monotonic, sleep from typing import TYPE_CHECKING, Any from .archive import validate_skill_archive @@ -154,7 +154,7 @@ def _wait_for_new_version( *, timeout_seconds: float = 90, ) -> Any: - deadline = time.monotonic() + timeout_seconds + deadline = monotonic() + timeout_seconds while True: new = [ item @@ -172,14 +172,14 @@ def _wait_for_new_version( latest.error_message or "新版本创建失败,请检查文件后重试", status_code=502, ) - if time.monotonic() >= deadline: + if monotonic() >= deadline: raise SkillRepositoryError( "SKILL_VERSION_PENDING", "新版本仍在处理中,请刷新版本列表查看结果", status_code=504, retryable=True, ) - time.sleep(2) + sleep(2) def upload( self, diff --git a/frontend/server/studio_routes/__init__.py b/frontend/server/studio_routes/__init__.py index 1a92b9e02..9d52727d6 100644 --- a/frontend/server/studio_routes/__init__.py +++ b/frontend/server/studio_routes/__init__.py @@ -14,18 +14,33 @@ """Studio BFF-owned dynamic HTTP routes and persistent Runtime connector.""" -from frontend.server.studio_routes.connector import ( - StudioRouteChannelError, - StudioRouteChannelManager, - runtime_supports_bff_routes, - serve_studio_route_channel, -) -from frontend.server.studio_routes.registry import ( - StudioRoute, - StudioRouteRegistry, - StudioRouteResponse, - build_studio_route_registry, -) +from __future__ import annotations + +import importlib +from typing import Any + + +_EXPORT_MODULES = { + "StudioRouteChannelError": "connector", + "StudioRouteChannelManager": "connector", + "runtime_supports_bff_routes": "connector", + "serve_studio_route_channel": "connector", + "StudioRoute": "registry", + "StudioRouteRegistry": "registry", + "StudioRouteResponse": "registry", + "build_studio_route_registry": "registry", +} + + +def __getattr__(name: str) -> Any: + module_name = _EXPORT_MODULES.get(name) + if module_name is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module = importlib.import_module(f"{__name__}.{module_name}") + value = getattr(module, name) + globals()[name] = value + return value + __all__ = [ "StudioRoute", diff --git a/frontend/server/studio_routes/registry.py b/frontend/server/studio_routes/registry.py index f9229bd3d..96213672a 100644 --- a/frontend/server/studio_routes/registry.py +++ b/frontend/server/studio_routes/registry.py @@ -21,19 +21,18 @@ import os from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any, Literal, cast from urllib.parse import parse_qs from frontend.server.storage import StudioProvider -from frontend.server.studio_routes.skill_catalog import ( - StudioSkillCatalog, - StudioSkillCatalogError, -) -from veadk.integrations.agentkit.studio_routes import ( +from veadk.integrations.agentkit.studio_routes.protocol import ( StudioRouteManifest, route_catalog_revision, ) +if TYPE_CHECKING: + from frontend.server.studio_routes.skill_catalog import StudioSkillCatalog + RouteExecutor = Callable[[dict[str, Any]], Any] @@ -61,11 +60,11 @@ class StudioRoute: def manifest(self) -> StudioRouteManifest: return StudioRouteManifest( id=self.id, - method=self.method.upper(), + method=cast(Literal["GET", "POST"], self.method.upper()), path=self.path, handler_revision=self.handler_revision, timeout_ms=self.timeout_ms, - response_mode=self.response_mode, + response_mode=cast(Literal["json", "text"], self.response_mode), ) @@ -130,7 +129,7 @@ async def execute( def _query_values(request: dict[str, Any]) -> dict[str, list[str]]: raw_query = request.get("query_string") if not isinstance(raw_query, str): - raise StudioSkillCatalogError(400, "invalid route query string") + raise _skill_catalog_error(400, "invalid route query string") try: return parse_qs( raw_query, @@ -139,7 +138,7 @@ def _query_values(request: dict[str, Any]) -> dict[str, list[str]]: max_num_fields=20, ) except ValueError as error: - raise StudioSkillCatalogError(400, "invalid route query string") from error + raise _skill_catalog_error(400, "invalid route query string") from error def _single_query( @@ -151,7 +150,7 @@ def _single_query( if not values: return default if len(values) != 1: - raise StudioSkillCatalogError(400, f"duplicate query parameter: {name}") + raise _skill_catalog_error(400, f"duplicate query parameter: {name}") return values[0] @@ -164,13 +163,19 @@ def _positive_int_query( try: return int(raw_value) except ValueError as error: - raise StudioSkillCatalogError( + raise _skill_catalog_error( 400, f"invalid integer query parameter: {name}", ) from error -def _catalog_response(error: StudioSkillCatalogError) -> StudioRouteResponse: +def _skill_catalog_error(status_code: int, detail: str) -> Exception: + from frontend.server.studio_routes.skill_catalog import StudioSkillCatalogError + + return StudioSkillCatalogError(status_code, detail) + + +def _catalog_response(error: Any) -> StudioRouteResponse: return StudioRouteResponse( status=error.status_code, headers={"content-type": "application/json"}, @@ -182,6 +187,8 @@ def _register_skill_catalog_routes( registry: StudioRouteRegistry, catalog: StudioSkillCatalog, ) -> None: + from frontend.server.studio_routes.skill_catalog import StudioSkillCatalogError + async def findskill(request: dict[str, Any]) -> StudioRouteResponse: try: query = _query_values(request) @@ -266,6 +273,8 @@ def build_studio_route_registry( registry = StudioRouteRegistry() mode = os.getenv("VEADK_STUDIO_ROUTE_CHANNEL", "").strip().lower() if mode in {"1", "true", "yes", "demo", "skill-catalog"}: + from frontend.server.studio_routes.skill_catalog import StudioSkillCatalog + _register_skill_catalog_routes( registry, skill_catalog or StudioSkillCatalog(provider), diff --git a/frontend/server/studio_tools/__init__.py b/frontend/server/studio_tools/__init__.py index f72c73c96..eb5a92043 100644 --- a/frontend/server/studio_tools/__init__.py +++ b/frontend/server/studio_tools/__init__.py @@ -14,40 +14,49 @@ """Studio BFF-owned dynamic tools and the Runtime WebSocket bridge.""" -from frontend.server.studio_tools.codex_sandbox import ( - CodexSandboxConnection, - CodexSandboxDelegate, - register_codex_sandbox_tool, -) -from frontend.server.studio_tools.connector import ( - StudioChannelError, - StudioToolRun, - open_studio_tool_run, - runtime_supports_bff_tools, -) -from frontend.server.studio_tools.local import ( - LocalStudioToolDispatcher, - build_local_studio_tools, - ensure_local_studio_toolset, - local_progress_sse_event, - stream_local_studio_response, -) -from frontend.server.studio_tools.registry import ( - StudioTool, - StudioToolCatalogSnapshot, - StudioToolExecutionContext, - StudioToolRegistry, - StudioToolRuntimeError, - build_studio_tool_registry, -) -from frontend.server.studio_tools.sandbox_shell import ( - AgentkitEnvironmentSandboxResolver, - SandboxExecutionTarget, - SandboxResolutionError, - SandboxTargetResolver, - execute_in_sandbox, - register_sandbox_shell_tool, -) +from __future__ import annotations + +import importlib +from typing import Any + + +_EXPORT_MODULES = { + "AgentkitEnvironmentSandboxResolver": "sandbox_shell", + "CodexSandboxConnection": "codex_sandbox", + "CodexSandboxDelegate": "codex_sandbox", + "LocalStudioToolDispatcher": "local", + "SandboxExecutionTarget": "sandbox_shell", + "SandboxResolutionError": "sandbox_shell", + "SandboxTargetResolver": "sandbox_shell", + "StudioChannelError": "connector", + "StudioTool": "registry", + "StudioToolCatalogSnapshot": "registry", + "StudioToolExecutionContext": "registry", + "StudioToolRegistry": "registry", + "StudioToolRun": "connector", + "StudioToolRuntimeError": "registry", + "build_local_studio_tools": "local", + "build_studio_tool_registry": "registry", + "ensure_local_studio_toolset": "local", + "execute_in_sandbox": "sandbox_shell", + "local_progress_sse_event": "local", + "open_studio_tool_run": "connector", + "register_codex_sandbox_tool": "codex_sandbox", + "register_sandbox_shell_tool": "sandbox_shell", + "runtime_supports_bff_tools": "connector", + "stream_local_studio_response": "local", +} + + +def __getattr__(name: str) -> Any: + module_name = _EXPORT_MODULES.get(name) + if module_name is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module = importlib.import_module(f"{__name__}.{module_name}") + value = getattr(module, name) + globals()[name] = value + return value + __all__ = [ "AgentkitEnvironmentSandboxResolver", diff --git a/frontend/server/studio_tools/branch_compare.py b/frontend/server/studio_tools/branch_compare.py index b04f07565..a5e134f39 100644 --- a/frontend/server/studio_tools/branch_compare.py +++ b/frontend/server/studio_tools/branch_compare.py @@ -22,17 +22,12 @@ from typing import Any from uuid import uuid4 -from google.adk.agents.run_config import RunConfig, StreamingMode -from google.adk.runners import InMemoryRunner -from google.genai import types - from frontend.server.studio_tools.registry import ( StudioTool, StudioToolExecutionContext, StudioToolExecutionError, StudioToolRegistry, ) -from veadk import Agent BRANCH_COMPARE_TOOL_NAME = "branch_compare" DEFAULT_BRANCH_MODEL = "doubao-seed-2-0-lite-260428" @@ -151,6 +146,15 @@ async def _generate_with_model( instruction: str, report_delta: BranchDeltaReporter, ) -> str: + # Building the Studio tool catalog is part of the Function readiness path. + # Import the model runtime only when this optional tool is actually invoked; + # these modules account for a large share of a cold Python startup. + from google.adk.agents.run_config import RunConfig, StreamingMode + from google.adk.runners import InMemoryRunner + from google.genai import types + + from veadk import Agent + model_name = os.getenv("VEADK_STUDIO_BRANCH_MODEL", DEFAULT_BRANCH_MODEL).strip() agent = Agent( name=f"studio_branch_{uuid4().hex[:12]}", diff --git a/frontend/server/studio_tools/builtin_declarations.py b/frontend/server/studio_tools/builtin_declarations.py new file mode 100644 index 000000000..63214d069 --- /dev/null +++ b/frontend/server/studio_tools/builtin_declarations.py @@ -0,0 +1,697 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Cold-start-safe declarations for Studio's canonical VeADK built-in tools.""" + +from __future__ import annotations + +from typing import Any + + +BUILTIN_TOOL_DECLARATIONS: dict[str, tuple[str, dict[str, Any]]] = { + "coding": ( + "Create code with the pre-configured OpenCode AgentKit sandbox.", + { + "additionalProperties": False, + "properties": { + "timeout": {"default": 900, "title": "Timeout", "type": "integer"}, + "workflow_prompt": {"title": "Workflow Prompt", "type": "string"}, + }, + "required": ["workflow_prompt"], + "title": "codingParams", + "type": "object", + }, + ), + "get_city_weather": ( + "Retrieves the weather information of a given city. the args must in English", + { + "additionalProperties": False, + "properties": {"city": {"title": "City", "type": "string"}}, + "required": ["city"], + "title": "get_city_weatherParams", + "type": "object", + }, + ), + "get_location_weather": ( + "Retrieves the weather information of a given city. the args must in English", + { + "additionalProperties": False, + "properties": {"city": {"title": "City", "type": "string"}}, + "required": ["city"], + "title": "get_location_weatherParams", + "type": "object", + }, + ), + "image_edit": ( + "Edit images in batch according to prompts and optional settings.\n" + "\n" + "Each item in `params` describes a single image-edit request.\n" + "\n" + "Args:\n" + " params (list[dict]):\n" + " A list of image editing requests. Each item supports:\n" + "\n" + " Required:\n" + " - origin_image (str):\n" + " The URL or Base64 string of the original image to " + "edit.\n" + " Example:\n" + ' * URL: "https://example.com/image.png"\n' + ' * Base64: "data:image/png;base64,"\n' + "\n" + " - prompt (str):\n" + " The textual description/instruction for editing the " + "image.\n" + " Supports English and Chinese.\n" + "\n" + " Optional:\n" + " - image_name (str):\n" + " Name/identifier for the generated image.\n" + "\n" + " - response_format (str):\n" + " Format of the returned image.\n" + ' * "url": JPEG link (default)\n' + ' * "b64_json": Base64 string in JSON\n' + "\n" + " - guidance_scale (float):\n" + " How strongly the prompt affects the result.\n" + " Range: [1.0, 10.0], default 2.5.\n" + "\n" + " - watermark (bool):\n" + " Whether to add watermark.\n" + " Default: True.\n" + "\n" + " - seed (int):\n" + " Random seed for reproducibility.\n" + " Range: [-1, 2^31-1], default -1 (random).\n" + "\n" + "Returns:\n" + " Dict: API response containing generated image metadata.\n" + " Example:\n" + " {\n" + ' "status": "success",\n' + ' "success_list": [{"image_name": ""}],\n' + ' "error_list": [{}]\n' + " }\n" + "\n" + "Notes:\n" + " - Uses SeedEdit 3.0 model.\n" + " - Provide the same `seed` for consistent outputs across runs.\n" + " - A high `guidance_scale` enforces stricter adherence to text " + "prompt.", + { + "additionalProperties": False, + "properties": {"params": {"items": {}, "title": "Params", "type": "array"}}, + "required": ["params"], + "title": "image_editParams", + "type": "object", + }, + ), + "image_generate": ( + "Generate images with Seedream 4.0 / 4.5 / 5.0\n" + "\n" + "Commit batch image generation requests via tasks.\n" + "\n" + "Args:\n" + " tasks (list[dict]):\n" + " A list of image-generation tasks. Each task is a dict.\n" + " timeout (int)\n" + " The timeout limit for the image generation task request, " + "in seconds, with a default value of 600 seconds.\n" + " model_name (str):\n" + " Optional model name. If not specified, use the default " + "model from environment.\n" + " If during execution, this tool encounters a model-related " + "error (note that it must be a model-related error, otherwise do " + "not perform this action), such as `ModelNotOpen`,\n" + " then after reminding about the relevant issue, you can " + "execute this tool again and downgrade the model to the following " + "models, passing this parameter:\n" + " - `doubao-seedream-5-0-260128`\n" + " - `doubao-seedream-4-5-251128`\n" + " - `doubao-seedream-4-0-250828`\n" + "Per-task schema\n" + "---------------\n" + "Required:\n" + " - task_type (str):\n" + " One of:\n" + ' * "multi_image_to_group" # 多图生组图\n' + ' * "single_image_to_group" # 单图生组图\n' + ' * "text_to_group" # 文生组图\n' + ' * "multi_image_to_single" # 多图生单图\n' + ' * "single_image_to_single" # 单图生单图\n' + ' * "text_to_single" # 文生单图\n' + " - prompt (str)\n" + " Text description of the desired image(s). 中文/English 均可。\n" + ' 若要指定生成图片的数量,请在prompt中添加"生成N张图片",其中N为具体的数字。\n' + "Optional:\n" + " - size (str)\n" + " 指定生成图像的大小,有两种用法(二选一,不可混用):\n" + " 方式 1:分辨率级别\n" + ' 可选值: "1K", "2K", "4K"\n' + " 模型会结合 prompt 中的语义推断合适的宽高比、长宽。\n" + " 方式 2:具体宽高值\n" + ' 格式: "<宽度>x<高度>",如 "2048x2048", "2384x1728"\n' + " 约束:\n" + " * 总像素数范围: [1024x1024, 4096x4096]\n" + " * 宽高比范围: [1/16, 16]\n" + " 推荐值:\n" + " - 1:1 → 2048x2048\n" + " - 4:3 → 2384x1728\n" + " - 3:4 → 1728x2304\n" + " - 16:9 → 2560x1440\n" + " - 9:16 → 1440x2560\n" + " - 3:2 → 2496x1664\n" + " - 2:3 → 1664x2496\n" + " - 21:9 → 3024x1296\n" + ' 默认值: "2048x2048"\n' + " - response_format (str)\n" + ' Return format: "url" (default, URL 24h 过期) | "b64_json".\n' + " - watermark (bool)\n" + " Add watermark. Default: true.\n" + ' - image (str | list[str]) # 仅"非文生图"需要。文生图请不要提供 image\n' + " Reference image(s) as URL or Base64.\n" + ' * 生成"单图"的任务:传入 string(exactly 1 image)。\n' + ' * 生成"组图"的任务:传入 array(2–10 images)。\n' + " - sequential_image_generation (str)\n" + ' 控制是否生成"组图"。Default: "disabled".\n' + ' * 若要生成组图:必须设为 "auto"。\n' + " - max_images (int)\n" + " 仅当生成组图时生效。控制模型能生成的最多张数,范围 [1, 15], 不设置默认为15。\n" + " 注意这个参数不等于生成的图片数量,而是模型最多能生成的图片数量。\n" + " 在单图组图场景最多 14;多图组图场景需满足 (len(images)+max_images ≤ 15)。\n" + " - tools (list[dict])\n" + " 工具配置,用于增强生成能力。目前支持联网搜索工具。\n" + ' 格式: [{"type": "web_search"}]\n' + " 注意:仅文生图(text_to_single / text_to_group)场景支持此参数,\n" + " 图生图场景(包含 image 参数)不支持使用 tools。\n" + " - output_format (str)\n" + ' 输出图片格式。枚举值: "png", "jpeg"。\n' + ' 默认值: "jpeg"(不传时模型默认)。\n' + " 注意:仅 Seedream 5.0 支持该字段,存量模型指定会报错。\n" + "Model 行为说明(如何由参数推断模式)\n" + "---------------------------------\n" + '1) 文生单图: 不提供 image 且 (S 未设置或 S="disabled") → 1 张图。\n' + '2) 文生组图: 不提供 image 且 S="auto" → 组图,数量由 max_images 控制。\n' + '3) 单图生单图: image=string 且 (S 未设置或 S="disabled") → 1 张图。\n' + '4) 单图生组图: image=string 且 S="auto" → 组图,数量 ≤14。\n' + '5) 多图生单图: image=array (2–10) 且 (S 未设置或 S="disabled") → 1 张图。\n' + '6) 多图生组图: image=array (2–10) 且 S="auto" → 组图,需满足总数 ≤15。\n' + "返回结果\n" + "--------\n" + " Dict with generation summary.\n" + " Example:\n" + " {\n" + ' "status": "success",\n' + ' "success_list": [\n' + ' {"image_name": "url"}\n' + " ],\n" + ' "error_list": ["image_name"],\n' + ' "error_detail_list": [\n' + ' {"task_idx": 0, "error": {"code": "InvalidParameter", ' + '"message": "..."}}\n' + " ]\n" + " }\n" + "Notes:\n" + '- 组图任务必须 sequential_image_generation="auto"。\n' + '- 如果想要指定生成组图的数量,请在prompt里添加数量说明,例如:"生成3张图片"。\n' + "- size 推荐使用 2048x2048 或表格里的标准比例,确保生成质量。", + { + "additionalProperties": False, + "properties": { + "model_name": { + "default": None, + "title": "Model Name", + "type": "string", + }, + "tasks": { + "items": {"additionalProperties": True, "type": "object"}, + "title": "Tasks", + "type": "array", + }, + "timeout": {"default": 600, "title": "Timeout", "type": "integer"}, + }, + "required": ["tasks"], + "title": "image_generateParams", + "type": "object", + }, + ), + "link_reader": ( + "Use this tool when you need to fetch content from web pages, PDFs, " + "or Douyin videos.\n" + "It retrieves the title and main content from the provided URLs.\n" + "\n" + 'Examples: {"url_list": ["abc.com", "xyz.com"]}\n' + "Args:\n" + " url_list (list[str]): A list of URLs to parse (maximum 3).\n" + "Returns:\n" + " list[dict]: A list of dictionaries, each containing the title " + "and content of the corresponding URL.", + { + "additionalProperties": False, + "properties": { + "url_list": { + "items": {"type": "string"}, + "title": "Url List", + "type": "array", + } + }, + "required": ["url_list"], + "title": "link_readerParams", + "type": "object", + }, + ), + "parallel_web_search": ( + "Search queries from websites in parallel.\n" + "\n" + "Args:\n" + " queries: The queries to search. Each query will be " + "searched in parallel.\n" + "\n" + "Returns:\n" + " A dict of query to result documents.", + { + "additionalProperties": False, + "properties": { + "queries": { + "items": {"type": "string"}, + "title": "Queries", + "type": "array", + } + }, + "required": ["queries"], + "title": "parallel_web_searchParams", + "type": "object", + }, + ), + "ppt_generate": ( + "Create a real PPTX file and attach it to the current conversation.\n" + "\n" + "Plan the complete deck before calling this tool. ``deck_markdown`` " + "uses a\n" + "simple flat format to avoid complex nested arguments: start every " + "content\n" + "slide with ``## Slide title``; add one plain-text summary line " + "followed by\n" + "3-7 ``- bullet`` lines. Add an optional ``Sources: URL | URL`` line " + "when\n" + "external claims or assets are used. The title slide is added " + "automatically.\n" + "\n" + "Args:\n" + " title: Audience-facing deck title.\n" + " deck_markdown: Ordered content slides in the flat Markdown " + "format.\n" + " tool_context: Current ADK tool context used to save the " + "result.\n" + " subtitle: Optional title-slide subtitle.\n" + " theme: Visual theme: blue, dark, warm, or green.\n" + " filename: Download filename ending in .pptx.\n" + "\n" + "Returns:\n" + " Metadata for the saved PowerPoint artifact.", + { + "additionalProperties": False, + "properties": { + "deck_markdown": {"title": "Deck Markdown", "type": "string"}, + "filename": { + "default": "presentation.pptx", + "title": "Filename", + "type": "string", + }, + "subtitle": {"default": "", "title": "Subtitle", "type": "string"}, + "theme": {"default": "blue", "title": "Theme", "type": "string"}, + "title": {"title": "Title", "type": "string"}, + }, + "required": ["title", "deck_markdown"], + "title": "ppt_generateParams", + "type": "object", + }, + ), + "run_code": ( + "Run code in a code sandbox and return the output.\n" + "For C++ code, don't execute it directly, compile and execute via " + "Python; write sources and object files to /tmp.\n" + "\n" + "Args:\n" + " code (str): The code to run.\n" + " language (str): The execution language. Use ``python3`` for code or " + "``bash`` for shell scripts.\n" + " timeout (int, optional): The timeout in seconds for the code " + "execution.\n" + " Defaults to 300 and must be between 1 and 300 seconds.\n" + " exec_dir (str, optional): Working directory for Bash execution. " + "Defaults to ``/tmp``.\n" + " env (dict[str, str], optional): Environment variables for Bash " + "execution.\n" + " hard_timeout (int, optional): Hard timeout for Bash execution. " + "Defaults\n" + " to 300 and must be between 1 and 300 seconds.\n" + " max_output_length (int, optional): Maximum Bash output length. " + "Defaults to 30000.\n" + "\n" + "Returns:\n" + " str: The output of the code execution.", + { + "additionalProperties": False, + "properties": { + "code": {"title": "Code", "type": "string"}, + "env": { + "anyOf": [ + {"additionalProperties": {"type": "string"}, "type": "object"}, + {"type": "null"}, + ], + "default": None, + "title": "Env", + }, + "exec_dir": {"default": "/tmp", "title": "Exec Dir", "type": "string"}, + "hard_timeout": { + "default": 300, + "title": "Hard Timeout", + "type": "integer", + }, + "language": {"title": "Language", "type": "string"}, + "max_output_length": { + "default": 30000, + "title": "Max Output Length", + "type": "integer", + }, + "timeout": {"default": 300, "title": "Timeout", "type": "integer"}, + }, + "required": ["code", "language"], + "title": "run_codeParams", + "type": "object", + }, + ), + "text_to_speech": ( + "TTS provides users with the ability to convert text to speech, " + "turning the text content of LLM into audio.\n" + "Use this tool when you need to convert text content into audible " + "speech.\n" + "It transforms plain text into natural-sounding speech, as well as " + "exporting the generated audio in pcm format.\n" + "\n" + "Args:\n" + " text: The text to convert.\n" + "\n" + "Returns:\n" + " A dict with the saved audio path.", + { + "additionalProperties": False, + "properties": {"text": {"title": "Text", "type": "string"}}, + "required": ["text"], + "title": "text_to_speechParams", + "type": "object", + }, + ), + "vesearch": ( + "Search information from Internet, social media, news sites, etc.\n" + "\n" + "Args:\n" + " query: The query string to search.\n" + "\n" + "Returns:\n" + " Summarized search results.", + { + "additionalProperties": False, + "properties": {"query": {"title": "Query", "type": "string"}}, + "required": ["query"], + "title": "vesearchParams", + "type": "object", + }, + ), + "video_generate": ( + "Generate videos in batch from text prompts, with support for " + "multiple input modes:\n" + "text-to-video, image-to-video (first/last frame), and multimodal " + "reference generation.\n" + "\n" + "This API creates video-generation tasks asynchronously. Each item " + "in `params` describes\n" + "a single video generation request. The function submits all items " + "and polls for results.\n" + "\n" + "If polling times out, the task_id will be returned so you can " + "query the status later\n" + "using the video_task_query tool.\n" + "\n" + "Args:\n" + " params (list[dict]):\n" + " A list of video generation requests. Each item is a dict " + "with the following fields.\n" + "\n" + " Required per item:\n" + " - video_name (str):\n" + " Name/identifier of the output video file.\n" + "\n" + " - prompt (str):\n" + " Text describing the video to generate. Supports " + "Chinese and English.\n" + " For multimodal reference generation, use [图1], " + "[图2], [视频1], [音频1]\n" + " to reference specific input materials in your " + "prompt.\n" + "\n" + " Optional per item - Input Materials:\n" + " - first_frame (str):\n" + " URL for the first frame image (role = " + "first_frame).\n" + " Use when you want the video to start from a " + "specific image.\n" + "\n" + " - last_frame (str):\n" + " URL for the last frame image (role = " + "last_frame).\n" + " Use when you want the video to end on a specific " + "image.\n" + "\n" + " - reference_images (list[str]):\n" + " 1-4 reference image URLs for style/content " + "guidance (role = reference_image).\n" + " The model extracts features from these images and " + "applies them to the output.\n" + " Use [图1], [图2], etc. in prompt to reference " + "specific images.\n" + "\n" + " - reference_videos (list[str]):\n" + " 0-3 reference video URLs for multimodal " + "generation (role = reference_video).\n" + " Video constraints: mp4/mov format, 2-15s duration " + "per video,\n" + " total duration <= 15s, size <= 50MB, 24-60 FPS.\n" + " Use [视频1], [视频2], etc. in prompt to reference " + "specific videos.\n" + "\n" + " - reference_audios (list[str]):\n" + " 0-3 reference audio URLs for multimodal " + "generation (role = reference_audio).\n" + " Audio constraints: mp3/wav format, 2-15s duration " + "per audio,\n" + " total duration <= 15s, size <= 15MB.\n" + " Use [音频1], [音频2], etc. in prompt to reference " + "specific audios.\n" + " Note: Audio cannot be used alone; must have at " + "least one image or video.\n" + "\n" + " Optional per item - Video Output Parameters:\n" + " - ratio (str):\n" + ' Aspect ratio. Options: "16:9" (default), "9:16", ' + '"4:3", "3:4", "1:1",\n' + ' "2:1", "21:9", "adaptive" (auto-select based on ' + "input).\n" + " Note: Reference image scenarios do not support " + "all ratios.\n" + "\n" + " - duration (int):\n" + " Video length in seconds. Range: 2-12s depending " + "on model.\n" + " - Seedance 1.5 pro: 4-12s\n" + " - Seedance 1.0 pro: 2-12s\n" + " - Seedance 1.0 pro-fast: 2-12s\n" + "\n" + " - resolution (str):\n" + ' Video resolution. Options: "480p", "720p", ' + '"1080p".\n' + " Default varies by model (e.g., Seedance 1.0 pro " + "defaults to 1080p).\n" + " Note: Reference image scenarios do not support " + "resolution parameter.\n" + "\n" + " - frames (int):\n" + " Total frame count. Must be in [29, 289] and " + "follow format 25 + 4n.\n" + " Alternative to duration for controlling video " + "length.\n" + "\n" + " - camera_fixed (bool):\n" + " Lock camera movement. true = fixed camera, false " + "= allow movement.\n" + " Default: false. Note: Not supported in reference " + "image scenarios.\n" + "\n" + " - seed (int):\n" + " Random seed for reproducibility. Range: [-1, " + "2^32-1].\n" + " Default: -1 (auto seed). Same seed may yield " + "similar results.\n" + "\n" + " - watermark (bool):\n" + " Whether to add watermark. Default: false.\n" + "\n" + " - generate_audio (bool):\n" + " Whether to generate audio. Only Seedance 1.5 pro " + "supports this.\n" + " If True, audio (ambi", + { + "additionalProperties": False, + "properties": { + "batch_size": {"default": 10, "title": "Batch Size", "type": "integer"}, + "max_wait_seconds": { + "default": 1200, + "title": "Max Wait Seconds", + "type": "integer", + }, + "model_name": { + "default": "doubao-seedance-2-0-260128", + "title": "Model Name", + "type": "string", + }, + "params": {"items": {}, "title": "Params", "type": "array"}, + }, + "required": ["params"], + "title": "video_generateParams", + "type": "object", + }, + ), + "video_task_query": ( + "Query the status of a video generation task.\n" + "\n" + "Use this tool to check the status of a previously submitted " + "video generation task.\n" + "If the task is completed, the video URL will be returned.\n" + "\n" + "Args:\n" + " task_id (str):\n" + " The task ID returned from video_generate when the task " + "was submitted.\n" + ' Format: "cgt-xxxxxxxxxxxx-xxxxx"\n' + "\n" + " tool_context (ToolContext):\n" + " The tool context provided by the ADK framework.\n" + "\n" + "Returns:\n" + " Dict:\n" + " {\n" + ' "task_id": "cgt-xxxxxxxxxxxx-xxxxx",\n' + ' "status": "succeeded" | "running" | "failed" | ' + '"queued",\n' + ' "video_url": "https://..." | None,\n' + ' "error": {...} | None,\n' + ' "model": "doubao-seedance-x-x",\n' + ' "created_at": timestamp,\n' + ' "updated_at": timestamp,\n' + ' "execution_expires_after": seconds\n' + " }\n" + "\n" + "Status Values:\n" + " - queued: Task is waiting in queue\n" + " - running: Task is being processed\n" + " - succeeded: Task completed, video_url available\n" + " - failed: Task failed, check error field\n" + "\n" + "Example:\n" + " # Query a task status\n" + ' result = await video_task_query("cgt-20260222165751-wsnw8", ' + "tool_context)\n" + ' if result["status"] == "succeeded":\n' + " print(f\"Video ready: {result['video_url']}\")\n" + ' elif result["status"] == "running":\n' + ' print("Still processing, please wait...")\n' + ' elif result["status"] == "failed":\n' + " print(f\"Task failed: {result['error']}\")", + { + "additionalProperties": False, + "properties": {"task_id": {"title": "Task Id", "type": "string"}}, + "required": ["task_id"], + "title": "video_task_queryParams", + "type": "object", + }, + ), + "web_fetch": ( + "Fetch a web page over HTTP(S) and return its readable main content.\n" + "\n" + "Performs a plain HTTP GET (no JavaScript execution) and extracts the " + "page's\n" + "readable text. Handles HTML pages (converted to markdown/text) and " + "**PDF**\n" + "URLs (text extracted via pypdf). Follows HTTP and `` " + "redirects.\n" + "Use it to read articles, docs, or any public URL the user references. " + "For\n" + "pages that require login or render entirely via JavaScript, the " + "content may\n" + "be incomplete.\n" + "\n" + "Args:\n" + " url: The http(s) URL to fetch.\n" + ' extract_mode: "markdown" (default, keeps headings/links/lists) or ' + '"text"\n' + " (plain text with markdown decoration removed).\n" + " max_chars: Truncate the extracted content to at most this many " + "characters.\n" + "\n" + "Returns:\n" + ' A dict with keys: "url" (final URL after redirects), "title", ' + '"content",\n' + ' and "truncated" (bool). On failure, a dict with an "error" key.', + { + "additionalProperties": False, + "properties": { + "extract_mode": { + "default": "markdown", + "title": "Extract Mode", + "type": "string", + }, + "max_chars": { + "default": 50000, + "title": "Max Chars", + "type": "integer", + }, + "url": {"title": "Url", "type": "string"}, + }, + "required": ["url"], + "title": "web_fetchParams", + "type": "object", + }, + ), + "web_search": ( + "Search a query in websites.\n" + "\n" + "Args:\n" + " query: The query to search.\n" + "\n" + "Returns:\n" + " A list of result documents.", + { + "additionalProperties": False, + "properties": {"query": {"title": "Query", "type": "string"}}, + "required": ["query"], + "title": "web_searchParams", + "type": "object", + }, + ), +} + + +__all__ = ["BUILTIN_TOOL_DECLARATIONS"] diff --git a/frontend/server/studio_tools/codex_sandbox.py b/frontend/server/studio_tools/codex_sandbox.py index 26bb9f668..f4de38a94 100644 --- a/frontend/server/studio_tools/codex_sandbox.py +++ b/frontend/server/studio_tools/codex_sandbox.py @@ -22,7 +22,7 @@ import re from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from dataclasses import dataclass -from typing import Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol import httpx @@ -42,25 +42,16 @@ SandboxResolutionError, SandboxTargetResolver, ) -from veadk.cli.codex_app_server import ( - CodexAppServerError, - CodexAppServerEvent, - CodexAppServerSession, - CodexAppServerTransportError, - CodexAppServerTurnTimeoutError, - CodexPermissionSettings, - sandbox_service_url, -) from veadk.utils.logger import get_logger +if TYPE_CHECKING: + from veadk.cli.codex_app_server import ( + CodexAppServerEvent, + CodexPermissionSettings, + ) + logger = get_logger(__name__) -_CODEX_PERMISSIONS = CodexPermissionSettings( - approval_policy="never", - approvals_reviewer="auto_review", - sandbox_mode="danger-full-access", - network_access=True, -) _CONNECT_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0) _READINESS_TIMEOUT_SECONDS = 5.0 _CODEX_TOOL_TIMEOUT_MS = 30 * 60 * 1_000 @@ -84,6 +75,51 @@ _URL_QUERY_RE = re.compile(r"https?://[^\s?]+\?[^\s]+") +def CodexAppServerSession(endpoint: str) -> CodexSandboxConnection: + """Construct the heavy app-server transport on the first delegated task.""" + + from veadk.cli.codex_app_server import CodexAppServerSession as _Session + + return _Session(endpoint) + + +def sandbox_service_url(endpoint: str, path: str) -> str: + """Resolve the private app-server URL only when readiness is probed.""" + + from veadk.cli.codex_app_server import sandbox_service_url as _service_url + + return _service_url(endpoint, path) + + +def _codex_permissions() -> CodexPermissionSettings: + from veadk.cli.codex_app_server import CodexPermissionSettings + + return CodexPermissionSettings( + approval_policy="never", + approvals_reviewer="auto_review", + sandbox_mode="danger-full-access", + network_access=True, + ) + + +def _codex_error_type() -> type[Exception]: + from veadk.cli.codex_app_server import CodexAppServerError + + return CodexAppServerError + + +def _codex_transport_error_type() -> type[Exception]: + from veadk.cli.codex_app_server import CodexAppServerTransportError + + return CodexAppServerTransportError + + +def _codex_turn_timeout_error_type() -> type[Exception]: + from veadk.cli.codex_app_server import CodexAppServerTurnTimeoutError + + return CodexAppServerTurnTimeoutError + + class CodexSandboxConnection(Protocol): """The narrow app-server surface required by the Studio adapter.""" @@ -119,19 +155,20 @@ def __init__( self, target_resolver: SandboxTargetResolver, *, - connection_factory: Callable[[str], CodexSandboxConnection] = ( - CodexAppServerSession - ), + connection_factory: Callable[[str], CodexSandboxConnection] | None = None, readiness_probe: Callable[[SandboxExecutionTarget], Awaitable[bool]] | None = None, sleep: Callable[[float], Awaitable[Any]] = asyncio.sleep, ) -> None: self._target_resolver = target_resolver - self._connection_factory = connection_factory + default_connection_factory = connection_factory is None + self._connection_factory = ( + connection_factory + if connection_factory is not None + else CodexAppServerSession + ) self._readiness_probe = readiness_probe or ( - _codex_app_server_ready - if connection_factory is CodexAppServerSession - else _always_ready + _codex_app_server_ready if default_connection_factory else _always_ready ) self._sleep = sleep self._connections: dict[ @@ -177,7 +214,7 @@ async def execute( try: entry = await self._ready_connection(target, mount, context) - except CodexAppServerTransportError as error: + except _codex_transport_error_type() as error: failure = await _report_failure(context, mount, "Codex Sandbox 连接失败") _append_activity_event(activity_events, failure) raise StudioToolRuntimeError( @@ -210,7 +247,7 @@ async def execute( try: async for event in entry.connection.stream_turn( prompt, - permissions=_CODEX_PERMISSIONS, + permissions=_codex_permissions(), ): if event.kind == "text": if event.text: @@ -231,7 +268,7 @@ async def execute( _progress_event(event, fallback_id=text_event_id), ), ) - except CodexAppServerTurnTimeoutError as error: + except _codex_turn_timeout_error_type() as error: failure = await _report_failure( context, mount, "Codex Sandbox 执行超时" ) @@ -247,7 +284,7 @@ async def execute( ok=False, ), ) from error - except CodexAppServerTransportError as error: + except _codex_transport_error_type() as error: failure = await _report_failure( context, mount, "Codex Sandbox 连接中断" ) @@ -264,7 +301,7 @@ async def execute( ok=False, ), ) from error - except CodexAppServerError as error: + except _codex_error_type() as error: failure = await _report_failure( context, mount, "Codex Sandbox 执行失败" ) @@ -325,7 +362,7 @@ async def _ready_connection( entry: _CodexConnectionEntry | None = None try: if not await self._readiness_probe(target): - raise CodexAppServerTransportError( + raise _codex_transport_error_type()( "Codex app-server readiness check did not pass." ) entry = await self._connection(target, mount, context) @@ -335,7 +372,7 @@ async def _ready_connection( async with entry.lock: await entry.connection.connect() return entry - except CodexAppServerTransportError as error: + except _codex_transport_error_type() as error: logger.warning( "Codex Sandbox app-server connection failed " "environment_id_prefix=%s attempt=%d/%d error_type=%s", diff --git a/frontend/server/studio_tools/local.py b/frontend/server/studio_tools/local.py index 7b937ee2f..f499efe38 100644 --- a/frontend/server/studio_tools/local.py +++ b/frontend/server/studio_tools/local.py @@ -21,24 +21,20 @@ import logging from collections.abc import AsyncIterator, Awaitable, Callable, Sequence from dataclasses import replace -from typing import Any +from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 -from google.adk.tools.base_tool import BaseTool - from frontend.server.studio_tools.registry import ( StudioToolCatalogSnapshot, StudioToolExecutionContext, StudioToolExecutionError, StudioToolRuntimeError, ) -from veadk.integrations.agentkit.studio_channel import ( - StudioExternalToolset, - StudioRemoteTool, - bind_studio_tools, -) from veadk.integrations.agentkit.studio_channel.protocol import StudioToolManifest +if TYPE_CHECKING: + from google.adk.tools.base_tool import BaseTool + logger = logging.getLogger(__name__) ProgressReporter = Callable[[dict[str, Any]], Awaitable[None]] @@ -142,6 +138,8 @@ def build_local_studio_tools( ) -> tuple[BaseTool, ...]: """Build immutable model-visible wrappers for one local Agent run.""" + from veadk.integrations.agentkit.studio_channel.tool import StudioRemoteTool + dispatcher = LocalStudioToolDispatcher( catalog=catalog, context=context, @@ -165,6 +163,8 @@ def ensure_local_studio_toolset( ) -> None: """Attach the run-scoped Studio toolset to a cached local ADK runner.""" + from veadk.integrations.agentkit.studio_channel.tool import StudioExternalToolset + app = getattr(runner, "app", None) root_agent = getattr(app, "root_agent", None) tools = getattr(root_agent, "tools", None) @@ -223,14 +223,20 @@ async def stream_local_studio_response( ) -> AsyncIterator[bytes | str]: """Merge direct tool progress into the local ADK SSE response.""" + from veadk.integrations.agentkit.studio_channel.tool import bind_studio_tools + source_task: asyncio.Task[bytes | str] | None = None progress_task: asyncio.Task[bytes] | None = None iterator = source.__aiter__() + + async def next_source() -> bytes | str: + return await anext(iterator) + try: with bind_studio_tools(tools): while True: if source_task is None: - source_task = asyncio.create_task(anext(iterator)) + source_task = asyncio.create_task(next_source()) if progress_task is None: progress_task = asyncio.create_task(progress_events.get()) done, _ = await asyncio.wait( @@ -260,7 +266,7 @@ async def stream_local_studio_response( ) aclose = getattr(iterator, "aclose", None) if callable(aclose): - await aclose() + await cast(Callable[[], Awaitable[None]], aclose)() __all__ = [ diff --git a/frontend/server/studio_tools/registry.py b/frontend/server/studio_tools/registry.py index 50f145210..e7df4559c 100644 --- a/frontend/server/studio_tools/registry.py +++ b/frontend/server/studio_tools/registry.py @@ -28,7 +28,7 @@ from jsonschema import Draft202012Validator from jsonschema.exceptions import SchemaError, ValidationError -from veadk.integrations.agentkit.studio_channel import ( +from veadk.integrations.agentkit.studio_channel.protocol import ( StudioToolManifest, catalog_revision, ) diff --git a/frontend/server/studio_tools/sandbox_shell.py b/frontend/server/studio_tools/sandbox_shell.py index d765e2073..06a59f42a 100644 --- a/frontend/server/studio_tools/sandbox_shell.py +++ b/frontend/server/studio_tools/sandbox_shell.py @@ -37,8 +37,6 @@ StudioToolExecutionError, StudioToolRegistry, ) -from veadk.cli.agentkit_session_metadata import build_create_session_request -from veadk.cli.codex_app_server import sandbox_service_url _READY_STATUS = "ready" _FAILED_TOOL_STATUSES = frozenset( @@ -52,6 +50,24 @@ _ALLOWED_TARGET_HEADERS = frozenset({"authorization", "x-api-key"}) +def build_create_session_request(*args: Any, **kwargs: Any) -> Any: + """Build Session metadata only when a mounted Sandbox needs a Session.""" + + from veadk.cli.agentkit_session_metadata import ( + build_create_session_request as _build_request, + ) + + return _build_request(*args, **kwargs) + + +def sandbox_service_url(endpoint: str, path: str) -> str: + """Resolve the Sandbox data-plane URL only for an actual tool call.""" + + from veadk.cli.codex_app_server import sandbox_service_url as _service_url + + return _service_url(endpoint, path) + + class SandboxTargetResolver(Protocol): async def resolve( self, diff --git a/frontend/server/studio_tools/veadk_builtin_tools.py b/frontend/server/studio_tools/veadk_builtin_tools.py index 07561ae61..a2a5b8428 100644 --- a/frontend/server/studio_tools/veadk_builtin_tools.py +++ b/frontend/server/studio_tools/veadk_builtin_tools.py @@ -17,23 +17,45 @@ from __future__ import annotations import asyncio +import copy +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any, cast from urllib.parse import quote -from google.adk.agents import Agent -from google.adk.agents.invocation_context import InvocationContext -from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService -from google.adk.sessions import InMemorySessionService, Session -from google.adk.tools import FunctionTool, ToolContext - +from frontend.server.studio_tools.builtin_declarations import ( + BUILTIN_TOOL_DECLARATIONS, +) from frontend.server.studio_tools.registry import ( StudioTool, StudioToolExecutionContext, StudioToolRegistry, ) -from veadk.multimodal.service import MediaService -from veadk.tools import get_builtin_tool, list_builtin_tools + +if TYPE_CHECKING: + from veadk.multimodal.service import MediaService + + +def FunctionTool(*args: Any, **kwargs: Any) -> Any: # noqa: N802 + """Construct ADK's adapter only when a built-in tool is first invoked.""" + + from google.adk.tools.function_tool import FunctionTool as _FunctionTool + + return _FunctionTool(*args, **kwargs) + + +def get_builtin_tool(name: str) -> Any: + """Resolve a canonical VeADK callable only for an actual tool call.""" + + from veadk.tools import get_builtin_tool as _get_builtin_tool + + return _get_builtin_tool(name) + + +def list_builtin_tools() -> list[str]: + """Return the declaration-locked built-in catalog without loading VeADK tools.""" + + return sorted(BUILTIN_TOOL_DECLARATIONS) _DISPLAY_NAMES = { @@ -75,41 +97,67 @@ "web_search", } +_DEFERRED_BUILTIN_DECLARATIONS = BUILTIN_TOOL_DECLARATIONS + @dataclass class _BuiltinExecutionHost: """Own the BFF-local ADK context needed by existing tool callables.""" - session_service: InMemorySessionService = field( - default_factory=InMemorySessionService - ) - artifact_service: InMemoryArtifactService = field( - default_factory=InMemoryArtifactService - ) + session_service: Any | None = None + artifact_service: Any | None = None media_service: MediaService | None = None - agent: Agent = field(default_factory=lambda: Agent(name="studio_bff_agent")) + agent: Any | None = None states: dict[str, dict[str, Any]] = field(default_factory=dict) locks: dict[str, asyncio.Lock] = field(default_factory=dict) + initialization_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + async def _ensure_runtime(self) -> None: + if self.session_service is not None: + return + async with self.initialization_lock: + if self.session_service is not None: + return + from google.adk.agents import Agent + from google.adk.artifacts.in_memory_artifact_service import ( + InMemoryArtifactService, + ) + from google.adk.sessions import InMemorySessionService + + self.session_service = InMemorySessionService() + self.artifact_service = InMemoryArtifactService() + self.agent = Agent(name="studio_bff_agent") async def execute( self, - function_tool: FunctionTool, + function_tool: Any, arguments: dict[str, Any], context: StudioToolExecutionContext, ) -> Any: + await self._ensure_runtime() + from google.adk.agents.invocation_context import InvocationContext + from google.adk.sessions import Session + from google.adk.tools.tool_context import ToolContext + + session_service = self.session_service + artifact_service = self.artifact_service + agent = self.agent + if session_service is None or artifact_service is None or agent is None: + raise RuntimeError("Studio built-in execution runtime is unavailable") + lock = self.locks.setdefault(context.scope_id, asyncio.Lock()) async with lock: session = Session( id=context.session_id, - appName=context.app_name, - userId=context.user_id, + app_name=context.app_name, + user_id=context.user_id, state=dict(self.states.get(context.scope_id, {})), ) invocation_context = InvocationContext( - artifact_service=self.artifact_service, - session_service=self.session_service, + artifact_service=artifact_service, + session_service=session_service, invocation_id=context.run_id, - agent=self.agent, + agent=agent, session=session, ) tool_context = ToolContext( @@ -133,16 +181,19 @@ async def execute( async def _publish_artifacts( self, - tool_context: ToolContext, + tool_context: Any, context: StudioToolExecutionContext, ) -> list[dict[str, Any]]: """Make ADK artifacts produced in BFF execution available to Studio.""" if self.media_service is None: return [] + artifact_service = self.artifact_service + if artifact_service is None: + raise RuntimeError("Studio built-in artifact runtime is unavailable") published: list[dict[str, Any]] = [] for filename, version in tool_context.actions.artifact_delta.items(): - artifact = await self.artifact_service.load_artifact( + artifact = await artifact_service.load_artifact( app_name=context.app_name, user_id=context.user_id, session_id=context.session_id, @@ -151,13 +202,17 @@ async def _publish_artifacts( ) if artifact is None or artifact.inline_data is None: continue + mime_type = artifact.inline_data.mime_type + data = artifact.inline_data.data + if not mime_type or data is None: + continue record = await self.media_service.save_bytes( app_name=context.app_name, user_id=context.user_id, session_id=context.session_id, file_name=filename, - mime_type=artifact.inline_data.mime_type, - data=artifact.inline_data.data, + mime_type=mime_type, + data=data, origin="model", ) ref = record.ref @@ -180,16 +235,71 @@ async def _publish_artifacts( return published -def _schema(function_tool: FunctionTool) -> tuple[str, dict[str, Any]]: +def _schema(function_tool: Any) -> tuple[str, dict[str, Any]]: declaration = function_tool._get_declaration() if declaration is None: raise ValueError(f"Built-in tool has no declaration: {function_tool.name}") - schema = dict(declaration.parameters_json_schema or {"type": "object"}) + schema: dict[str, Any] = dict( + declaration.parameters_json_schema or {"type": "object"} + ) schema.setdefault("additionalProperties", False) description = (declaration.description or function_tool.name).strip()[:4096] return description, schema +def _deferred_schema(name: str) -> tuple[str, dict[str, Any]]: + description, schema = _DEFERRED_BUILTIN_DECLARATIONS[name] + copied_schema = copy.deepcopy(schema) + copied_schema.setdefault("additionalProperties", False) + return description, copied_schema + + +def _normalize_schema_value(value: Any) -> Any: + """Normalize the nullable schema form that varies across Pydantic runtimes.""" + + if isinstance(value, list): + return [_normalize_schema_value(item) for item in value] + if not isinstance(value, dict): + return value + + normalized = {key: _normalize_schema_value(item) for key, item in value.items()} + any_of = normalized.get("anyOf") + if ( + not isinstance(any_of, list) + or len(any_of) != 2 + or "default" not in normalized + or normalized["default"] is not None + ): + return normalized + + null_branches = [branch for branch in any_of if branch == {"type": "null"}] + value_branches = [branch for branch in any_of if branch != {"type": "null"}] + if ( + len(null_branches) != 1 + or len(value_branches) != 1 + or not isinstance(value_branches[0], dict) + ): + return normalized + + merged = {key: item for key, item in normalized.items() if key != "anyOf"} + for key, item in value_branches[0].items(): + if key in merged and merged[key] != item: + return normalized + merged[key] = item + return merged + + +def _schemas_match( + left: tuple[str, dict[str, Any]], + right: tuple[str, dict[str, Any]], +) -> bool: + """Compare deferred declarations across supported Python/Pydantic forms.""" + + return left[0] == right[0] and _normalize_schema_value( + left[1] + ) == _normalize_schema_value(right[1]) + + def register_veadk_builtin_tools( registry: StudioToolRegistry, *, @@ -198,16 +308,35 @@ def register_veadk_builtin_tools( """Expose the existing VeADK built-ins through the Studio-owned channel.""" host = _BuiltinExecutionHost(media_service=media_service) + resolved_tools: dict[str, Any] = {} for name in list_builtin_tools(): - function_tool = FunctionTool(get_builtin_tool(name)) - description, input_schema = _schema(function_tool) + if name in _DEFERRED_BUILTIN_DECLARATIONS: + description, input_schema = _deferred_schema(name) + else: + function_tool = FunctionTool( + cast(Callable[..., Any], get_builtin_tool(name)) + ) + resolved_tools[name] = function_tool + description, input_schema = _schema(function_tool) async def execute( arguments: dict[str, Any], context: StudioToolExecutionContext, *, - current_tool: FunctionTool = function_tool, + current_name: str = name, ) -> Any: + current_tool = resolved_tools.get(current_name) + if current_tool is None: + current_tool = FunctionTool( + cast(Callable[..., Any], get_builtin_tool(current_name)) + ) + if not _schemas_match( + _schema(current_tool), _deferred_schema(current_name) + ): + raise RuntimeError( + f"Deferred built-in declaration changed: {current_name}" + ) + resolved_tools[current_name] = current_tool return await host.execute(current_tool, arguments, context) registry.register( diff --git a/frontend/server/user_management/deployment.py b/frontend/server/user_management/deployment.py index 9ecd9886f..571212ea0 100644 --- a/frontend/server/user_management/deployment.py +++ b/frontend/server/user_management/deployment.py @@ -176,14 +176,16 @@ def initialize_runtime_roles( identity_region: str, credentials: Callable[[], tuple[str, str, str | None]], environment: Mapping[str, str], - super_admin: str = "", - admins: str = "", - developers: str = "", ) -> UserManagementService: - """Migrate older releases on startup before accepting authenticated requests""" + """Read and validate roles that a deployment identity already initialized.""" initialized = environment.get( "VEADK_STUDIO_IDENTITY_ROLES", "" ).strip().lower() in {"1", "true", "yes"} + if not initialized: + raise click.ClickException( + "Studio Identity role setup failed: roles_not_initialized; run the " + "deployment Identity migration before starting Studio" + ) service = UserManagementService( IdentityDirectory(pool_uid, provider, identity_region, credentials), pool_uid, @@ -191,37 +193,10 @@ def initialize_runtime_roles( provider, ) try: - service.initialize( - super_admin, admins, developers, allow_initialize=not initialized - ) + service.initialize(allow_initialize=False) except UserManagementError as error: raise click.ClickException( f"Studio Identity role setup failed: {error.code}; check Identity user/group " "permissions and the legacy role lists, then retry" ) from error - legacy_keys = ( - "VEADK_STUDIO_SUPER_ADMIN", - "VEADK_STUDIO_ADMINS", - "VEADK_STUDIO_DEVELOPERS", - ) - if (not initialized or any(environment.get(key) for key in legacy_keys)) and ( - function_id := environment.get("VEADK_STUDIO_FUNCTION_ID") - ): - from veadk.integrations.ve_faas.ve_faas import VeFaaS - - ak, sk, token = credentials() - deployment = VeFaaS( - access_key=ak, - secret_key=sk, - session_token=token or "", - region=environment.get("VEADK_STUDIO_DEPLOY_REGION") or identity_region, - project_name=environment.get("VEADK_STUDIO_PROJECT", "default"), - provider=provider, - ) - clear_legacy_role_environment( - function_client=deployment.client, - function_id=function_id, - pool_uid=pool_uid, - client_uid=client_uid, - ) return service diff --git a/frontend/server/user_management/directory.py b/frontend/server/user_management/directory.py index 7aefc1a30..758faf0bb 100644 --- a/frontend/server/user_management/directory.py +++ b/frontend/server/user_management/directory.py @@ -18,10 +18,7 @@ from dataclasses import dataclass from typing import Any -import volcenginesdkcore -import volcenginesdkid as sdk from urllib3.exceptions import HTTPError -from volcenginesdkcore.rest import ApiException from veadk.utils.cloud_provider import ( CloudProvider, @@ -32,6 +29,13 @@ from .errors import UserManagementError +def _identity_sdk() -> Any: + """Load generated Identity models only when a directory call is made.""" + import volcenginesdkid + + return volcenginesdkid + + @dataclass(frozen=True) class PoolUser: uid: str @@ -81,9 +85,21 @@ def __init__( self.region = region self.credentials = credentials + def _resolve_credentials(self) -> tuple[str, str, str | None]: + try: + return self.credentials() + except UserManagementError: + raise + except Exception as error: + raise UserManagementError(503, "identity_unavailable") from error + def _call(self, action: str, body: Any) -> Any: + import volcenginesdkcore + from volcenginesdkcore.rest import ApiException + + sdk = _identity_sdk() # Resolve credentials for each call so rotating cloud credentials remain valid - ak, sk, token = self.credentials() + ak, sk, token = self._resolve_credentials() # The generated SDK types host as None although it accepts URL strings config: Any = volcenginesdkcore.Configuration() config.ak, config.sk, config.session_token = ak, sk, token or "" @@ -102,6 +118,7 @@ def _call(self, action: str, body: Any) -> Any: raise UserManagementError(503, "identity_unavailable") from error def users(self) -> list[PoolUser]: + sdk = _identity_sdk() users: list[PoolUser] = [] page = 1 while True: @@ -120,6 +137,7 @@ def users(self) -> list[PoolUser]: page += 1 def user(self, uid: str) -> PoolUser: + sdk = _identity_sdk() return PoolUser.from_sdk( self._call( "get_user", @@ -131,6 +149,7 @@ def user(self, uid: str) -> PoolUser: ) def groups(self) -> list[IdentityGroup]: + sdk = _identity_sdk() groups: list[IdentityGroup] = [] page = 1 while True: @@ -152,6 +171,7 @@ def groups(self) -> list[IdentityGroup]: page += 1 def create_group(self, name: str, description: str) -> IdentityGroup: + sdk = _identity_sdk() result = self._call( "create_group", sdk.CreateGroupRequest( @@ -163,6 +183,7 @@ def create_group(self, name: str, description: str) -> IdentityGroup: return IdentityGroup(result.uid, name, description) def describe_group(self, uid: str, description: str) -> None: + sdk = _identity_sdk() self._call( "update_group", sdk.UpdateGroupRequest( @@ -173,6 +194,7 @@ def describe_group(self, uid: str, description: str) -> None: ) def add(self, group_uid: str, user_uid: str) -> None: + sdk = _identity_sdk() self._call( "add_users_to_group", sdk.AddUsersToGroupRequest( @@ -183,6 +205,7 @@ def add(self, group_uid: str, user_uid: str) -> None: ) def remove(self, group_uid: str, user_uid: str) -> None: + sdk = _identity_sdk() self._call( "remove_users_from_group", sdk.RemoveUsersFromGroupRequest( diff --git a/frontend/server/video/routes.py b/frontend/server/video/routes.py index 43f7611ff..755acf0a3 100644 --- a/frontend/server/video/routes.py +++ b/frontend/server/video/routes.py @@ -20,22 +20,14 @@ import tempfile from collections.abc import Callable from pathlib import Path -from typing import Annotated, Literal +from threading import Lock +from typing import TYPE_CHECKING, Annotated, Any, Literal import httpx from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.responses import StreamingResponse from starlette.background import BackgroundTask -from .client import ( - ArkHttpClient, - ArkPromptClient, - ArkServiceError, - ArkTokenCache, - ArkTokenProvider, - ArkVideoClient, - CredentialResolver, -) from .models import ( PromptEnhanceRequest, PromptEnhanceResponse, @@ -46,18 +38,11 @@ VideoTaskCreateRequest, VideoTaskResponse, ) -from .service import ( - VideoInputError, - VideoService, - VideoTaskAccessDenied, - VideoTaskNotFound, -) -from .storage import ( - LazyVideoAssetRepository, - VideoAssetNotFound, - VideoAssetStorageUnavailable, - video_asset_repository_factory, -) + +if TYPE_CHECKING: + from .service import VideoService + +CredentialResolver = Callable[[], tuple[str, str, str | None]] IdentityResolver = Callable[[Request], str] @@ -84,9 +69,72 @@ def build_video_service( http_client: httpx.AsyncClient | None = None, token_loader: Callable[..., str] | None = None, ) -> VideoService: - """Compose production dependencies while keeping each layer testable.""" + """Compose production dependencies on the first video request.""" if provider not in _PROVIDER_DEFAULTS: raise ValueError(f"Unsupported video provider: {provider}") + return _LazyVideoService( + provider=provider, + resolve_credentials=resolve_credentials, + http_client=http_client, + token_loader=token_loader, + ) # type: ignore[return-value] + + +class _LazyVideoService: + """Keep ModelArk, media storage, and provider SDKs off Studio startup.""" + + def __init__( + self, + *, + provider: Literal["volcengine", "byteplus"], + resolve_credentials: CredentialResolver, + http_client: httpx.AsyncClient | None, + token_loader: Callable[..., str] | None, + ) -> None: + self._provider: Literal["volcengine", "byteplus"] = provider + self._resolve_credentials = resolve_credentials + self._http_client = http_client + self._token_loader = token_loader + self._service: Any | None = None + self._lock = Lock() + + def _resolve(self) -> VideoService: + if self._service is not None: + return self._service + with self._lock: + if self._service is None: + self._service = _build_video_service( + provider=self._provider, + resolve_credentials=self._resolve_credentials, + http_client=self._http_client, + token_loader=self._token_loader, + ) + return self._service + + def __getattr__(self, name: str) -> Any: + return getattr(self._resolve(), name) + + +def _build_video_service( + *, + provider: Literal["volcengine", "byteplus"], + resolve_credentials: CredentialResolver, + http_client: httpx.AsyncClient | None, + token_loader: Callable[..., str] | None, +) -> VideoService: + from .client import ( + ArkHttpClient, + ArkPromptClient, + ArkTokenCache, + ArkTokenProvider, + ArkVideoClient, + ) + from .service import VideoService + from .storage import ( + LazyVideoAssetRepository, + video_asset_repository_factory, + ) + defaults = _PROVIDER_DEFAULTS[provider] config = VideoProviderConfig( provider=provider, @@ -190,10 +238,9 @@ async def upload_asset( declared_mime_type=file.content_type or "", source=temp_path, ) - except VideoAssetStorageUnavailable as error: - raise HTTPException(status_code=503, detail=str(error)) from error - except ValueError as error: - raise HTTPException(status_code=400, detail=str(error)) from error + except Exception as error: + _raise_api_error(error) + raise finally: await file.close() if temp_path is not None: @@ -267,6 +314,13 @@ async def download_task(task_id: str, request: Request) -> StreamingResponse: def _raise_api_error(error: Exception) -> None: + from .client import ArkServiceError + from .service import VideoInputError, VideoTaskAccessDenied, VideoTaskNotFound + from .storage import ( + VideoAssetNotFound, + VideoAssetStorageUnavailable, + ) + if isinstance(error, (VideoTaskNotFound, VideoAssetNotFound)): raise HTTPException(status_code=404, detail=str(error)) from error if isinstance(error, VideoTaskAccessDenied): diff --git a/frontend/server/workspace_preview.py b/frontend/server/workspace_preview.py index 8a276aaa1..79a2fbc84 100644 --- a/frontend/server/workspace_preview.py +++ b/frontend/server/workspace_preview.py @@ -27,6 +27,7 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel, Field +from frontend.server.sandbox_remote_errors import SandboxRemoteError from veadk.cli.frontend_sandbox import ( SandboxCloudGateway, SandboxCloudSession, @@ -130,22 +131,29 @@ def mount_workspace_preview_routes( creator_resolver: Callable[[Request], str], ) -> None: tool_id = (os.getenv("STUDIO_WORKSPACE_TOOL_ID") or "").strip() - from frontend.server.workspace_projects import PersistentWorkspaceProjects - from frontend.server.sandbox_remote import SandboxRemoteError + projects = None - projects = PersistentWorkspaceProjects(gateway, tool_id) + def project_service(): + nonlocal projects + if projects is None: + from frontend.server.workspace_projects import PersistentWorkspaceProjects - def require_tool() -> None: + projects = PersistentWorkspaceProjects(gateway, tool_id) + return projects + + def require_tool(): nonlocal tool_id tool_id = (os.getenv("STUDIO_WORKSPACE_TOOL_ID") or "").strip() - projects.tool_id = tool_id if not tool_id: raise HTTPException(503, "请先配置工作区 Sandbox 镜像") + service = project_service() + service.tool_id = tool_id + return service @app.get("/web/workspace-preview/state") async def workspace_state(request: Request) -> JSONResponse: owner = owner_resolver(request) - require_tool() + projects = require_tool() try: return JSONResponse( await projects.state(owner), @@ -157,7 +165,7 @@ async def workspace_state(request: Request) -> JSONResponse: @app.get("/web/workspace-preview/projects") async def list_projects(request: Request) -> JSONResponse: owner = owner_resolver(request) - require_tool() + projects = require_tool() try: cloud, names = await projects.list(owner, creator_resolver(request)) details = await projects.describe(cloud, names) @@ -175,7 +183,7 @@ async def list_projects(request: Request) -> JSONResponse: @app.post("/web/workspace-preview/projects") async def create_project(request: Request, body: ProjectInput) -> JSONResponse: owner = owner_resolver(request) - require_tool() + projects = require_tool() try: cloud = await projects.create(owner, creator_resolver(request), body.name) return project_response(cloud, body.name) @@ -185,7 +193,7 @@ async def create_project(request: Request, body: ProjectInput) -> JSONResponse: @app.post("/web/workspace-preview/projects/{project_name}/open") async def open_project(request: Request, project_name: str) -> JSONResponse: owner = owner_resolver(request) - require_tool() + projects = require_tool() try: cloud = await projects.open(owner, creator_resolver(request), project_name) return project_response(cloud, project_name) diff --git a/frontend/server/workspace_projects.py b/frontend/server/workspace_projects.py index 900c098b4..4ab05b486 100644 --- a/frontend/server/workspace_projects.py +++ b/frontend/server/workspace_projects.py @@ -23,17 +23,29 @@ import shlex from dataclasses import replace from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any from agentkit.sdk.tools.types import SetSessionTtlRequest -from typing import Any from fastapi import HTTPException -from frontend.server.sandbox_remote import SandboxRemoteTransport from frontend.server.workspace_preview import ProjectInput, _expired from veadk.cli.agentkit_session_metadata import build_create_session_request from veadk.cli.frontend_sandbox import SandboxCloudSession, STUDIO_SANDBOX_TTL_SECONDS +if TYPE_CHECKING: + from frontend.server.sandbox_remote import SandboxRemoteTransport +else: + + def SandboxRemoteTransport(endpoint: str): # noqa: N802 + """Load the remote transport only when a workspace request needs it.""" + from frontend.server.sandbox_remote import ( + SandboxRemoteTransport as _SandboxRemoteTransport, + ) + + return _SandboxRemoteTransport(endpoint) + + _KIND = "studio-workspace" _TRANSITIONAL_SESSIONS = { "creating", diff --git a/frontend/server/workspace_tool.py b/frontend/server/workspace_tool.py index 5111353b9..7c452b3da 100644 --- a/frontend/server/workspace_tool.py +++ b/frontend/server/workspace_tool.py @@ -21,9 +21,10 @@ import secrets import time from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any -from agentkit.sdk.tools import types +if TYPE_CHECKING: + from frontend.server.storage import StudioProvider DEFAULT_WORKSPACE_IMAGES = { ( @@ -41,6 +42,12 @@ } +def _tools_types(): + from agentkit.sdk.tools import types + + return types + + def resolve_workspace_image(provider: str, region: str) -> str: override = os.getenv("STUDIO_WORKSPACE_IMAGE", "").strip() if override: @@ -56,6 +63,7 @@ def resolve_workspace_image(provider: str, region: str) -> str: def workspace_tool_request( image: str, provider: str, model_environment: Mapping[str, str] ) -> Any: + types = _tools_types() if provider not in {"volcengine", "byteplus"}: raise ValueError("Unsupported workspace provider") if not image or image != image.strip() or "://" in image or "/" not in image: @@ -116,6 +124,7 @@ def ensure_workspace_tool( model_environment: Mapping[str, str], timeout: float = 600, ) -> str: + types = _tools_types() request = workspace_tool_request(image, provider, model_environment) response = client.list_tools( types.ListToolsRequest( @@ -239,7 +248,9 @@ def workspace_update_environment( return {"STUDIO_WORKSPACE_TOOL_ID": tool_id} -def repair_deployed_workspace_binding(*, provider: str, resolve_credentials) -> str: +def repair_deployed_workspace_binding( + *, provider: StudioProvider, resolve_credentials +) -> str: """Backfill older releases whose updater did not know about workspace Tools.""" import volcenginesdkvefaas as faas from veadk.integrations.ve_faas.ve_faas import VeFaaS @@ -258,7 +269,9 @@ def repair_deployed_workspace_binding(*, provider: str, resolve_credentials) -> ) def read_environment(): - function = service.client.get_function(faas.GetFunctionRequest(id=function_id)) + function: Any = service.client.get_function( + faas.GetFunctionRequest(id=function_id) + ) return {item.key: item.value for item in function.envs or []} environment = read_environment() @@ -291,7 +304,9 @@ def read_environment(): return overrides["STUDIO_WORKSPACE_TOOL_ID"] -def mount_workspace_upgrade_repair(app, *, provider: str, resolve_credentials) -> None: +def mount_workspace_upgrade_repair( + app, *, provider: StudioProvider, resolve_credentials +) -> None: """Run only for deployed Studios lacking the new binding, without blocking HTTP.""" if os.getenv("STUDIO_WORKSPACE_TOOL_ID", "").strip() or not os.getenv( "VEADK_STUDIO_FUNCTION_ID" diff --git a/frontend/service/studio_release_server/deploy.py b/frontend/service/studio_release_server/deploy.py index 8224af6c9..75bdc0c0a 100644 --- a/frontend/service/studio_release_server/deploy.py +++ b/frontend/service/studio_release_server/deploy.py @@ -264,7 +264,28 @@ def _stage_deployment(source_root: Path, destination: Path) -> None: (site_packages / ".installed").touch() run_script = destination / "run.sh" shutil.copy2(deployment_root / "run.sh", run_script) - run_script.chmod(0o755) + _normalize_runtime_permissions(destination) + + +def _normalize_runtime_permissions(deployment_root: Path) -> None: + """Make staged files readable by the unprivileged VeFaaS runtime user.""" + deployment_root.chmod(0o755) + for current_root, directory_names, file_names in os.walk(deployment_root): + current_path = Path(current_root) + for directory_name in directory_names: + directory = current_path / directory_name + if not directory.is_symlink(): + directory.chmod(0o755) + for file_name in file_names: + file_path = current_path / file_name + if file_path.is_symlink(): + continue + relative_path = file_path.relative_to(deployment_root) + executable = file_path.suffix == ".sh" or relative_path.parts[:2] == ( + "site-packages", + "bin", + ) + file_path.chmod(0o755 if executable else 0o644) def _stage_node_archive(service_destination: Path) -> None: diff --git a/frontend/service/studio_release_server/offline_runtime.py b/frontend/service/studio_release_server/offline_runtime.py index 454bb312b..ffce8c33f 100644 --- a/frontend/service/studio_release_server/offline_runtime.py +++ b/frontend/service/studio_release_server/offline_runtime.py @@ -16,10 +16,18 @@ from __future__ import annotations +import base64 +import compileall +import csv +import io import os +import py_compile +import re import shutil import subprocess +import sys import tempfile +import zipfile from collections.abc import Mapping, Sequence from hashlib import sha256 from pathlib import Path @@ -53,6 +61,32 @@ "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", ) +_COLD_START_WHEEL_ROOTS = { + "veadk-python": ("veadk", "frontend"), + "google-adk": ("google/adk",), + "agentkit-sdk-python": ("agentkit",), + "google-genai": ("google/genai",), + "mcp": ("mcp",), + "dateparser": ("dateparser",), + "fastapi": ("fastapi",), + "sqlalchemy": ("sqlalchemy",), + "aiohttp": ("aiohttp",), + "tos": ("tos",), + "trafilatura": ("trafilatura",), + "htmldate": ("htmldate",), + "authlib": ("authlib",), + "volcengine-python-sdk": ( + "volcenginesdkid", + "volcenginesdkvpc", + "volcenginesdkarkruntime", + "volcenginesdkvefaas", + "volcenginesdkark", + "volcenginesdkcore", + ), +} +_COLD_START_SOURCE_EXCLUSIONS = re.compile( + r"(?:template[/\\]\{\{|resources[/\\]samples)" +) def _lock_check_environment(environment: Mapping[str, str]) -> dict[str, str]: @@ -71,6 +105,7 @@ def build_studio_offline_runtime( veadk_wheel: Path, dependency_sources: Sequence[Path], environment: Mapping[str, str] | None = None, + optimize_cold_start: bool = False, ) -> str: """Bundle every locked Linux dependency and return offline requirements.""" lock_source = source_root / "uv.lock" @@ -192,6 +227,10 @@ def build_studio_offline_runtime( shutil.move(str(veadk_wheel), staged_veadk) if not staged_veadk.is_file(): raise ValueError("Studio offline wheelhouse is incomplete.") + if optimize_cold_start: + if sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 12): + raise ValueError("Studio cold-start optimization requires CPython 3.12.") + _enhance_studio_cold_start_wheels(wheelhouse) _pin_runtime_lock_to_wheelhouse(runtime_lock, wheelhouse, staged_veadk) for wheel in sorted(wheelhouse.glob("*.whl")): destination = package_dir / wheel.name @@ -217,6 +256,135 @@ def build_studio_offline_runtime( return requirements +def _enhance_studio_cold_start_wheels(wheelhouse: Path) -> dict[str, int]: + """Add deterministic checked-hash CPython 3.12 bytecode to hot wheels.""" + if sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 12): + raise ValueError("Studio cold-start optimization requires CPython 3.12.") + selected: dict[str, tuple[Path, tuple[str, ...]]] = {} + for wheel in sorted(wheelhouse.glob("*.whl")): + try: + name, _version, _build, _tags = parse_wheel_filename(wheel.name) + except InvalidWheelFilename as error: + raise ValueError("Studio wheelhouse contains an invalid wheel.") from error + distribution = canonicalize_name(name) + roots = _COLD_START_WHEEL_ROOTS.get(distribution) + if roots is None: + continue + if distribution in selected: + raise ValueError( + "Studio cold-start wheelhouse contains duplicate distributions." + ) + selected[distribution] = (wheel, roots) + if set(selected) != set(_COLD_START_WHEEL_ROOTS): + raise ValueError("Studio cold-start wheelhouse is incomplete.") + + enhanced: dict[str, int] = {} + for distribution in _COLD_START_WHEEL_ROOTS: + wheel, roots = selected[distribution] + enhanced[wheel.name] = _augment_checked_hash_wheel(wheel, roots) + return enhanced + + +def _augment_checked_hash_wheel(wheel: Path, roots: tuple[str, ...]) -> int: + """Repack one wheel with checked-hash pyc files and a valid RECORD.""" + with tempfile.TemporaryDirectory(prefix="veadk_studio_pyc_") as tmp: + extracted = Path(tmp) / "wheel" + extracted.mkdir() + try: + with zipfile.ZipFile(wheel) as source: + original = [(info, source.read(info)) for info in source.infolist()] + source.extractall(extracted) + except (OSError, zipfile.BadZipFile) as error: + raise ValueError("Studio cold-start wheel is invalid.") from error + + pycs: list[Path] = [] + for relative in roots: + source_root = extracted / relative + if not source_root.is_dir(): + raise ValueError("Studio cold-start wheel is missing a hot path.") + if not compileall.compile_dir( + source_root, + quiet=1, + force=True, + rx=_COLD_START_SOURCE_EXCLUSIONS, + stripdir=str(extracted), + prependdir="/opt/application/site-packages", + invalidation_mode=py_compile.PycInvalidationMode.CHECKED_HASH, + ): + raise ValueError("Studio cold-start bytecode compilation failed.") + pycs.extend(source_root.rglob("*.pyc")) + pycs = sorted(set(pycs)) + if not pycs: + raise ValueError("Studio cold-start wheel produced no bytecode.") + for pyc in pycs: + header = pyc.read_bytes()[:8] + if len(header) != 8 or int.from_bytes(header[4:8], "little") != 3: + raise ValueError("Studio cold-start bytecode is not checked-hash.") + + record_entries = [ + (info, content) + for info, content in original + if info.filename.endswith(".dist-info/RECORD") + ] + if len(record_entries) != 1: + raise ValueError("Studio cold-start wheel RECORD is invalid.") + record_info, record_content = record_entries[0] + rows = [ + row + for row in csv.reader(io.StringIO(record_content.decode("utf-8"))) + if row + ] + pyc_names = {path.relative_to(extracted).as_posix() for path in pycs} + rows = [ + row + for row in rows + if row[0] != record_info.filename and row[0] not in pyc_names + ] + for pyc in pycs: + content = pyc.read_bytes() + digest = base64.urlsafe_b64encode(sha256(content).digest()) + rows.append( + [ + pyc.relative_to(extracted).as_posix(), + "sha256=" + digest.rstrip(b"=").decode("ascii"), + str(len(content)), + ] + ) + rows.append([record_info.filename, "", ""]) + record_output = io.StringIO(newline="") + csv.writer(record_output, lineterminator="\n").writerows(rows) + + rebuilt = Path(tmp) / wheel.name + with zipfile.ZipFile( + rebuilt, + "w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as destination: + for info, content in original: + if info.filename in pyc_names: + continue + destination.writestr( + info, + ( + record_output.getvalue().encode("utf-8") + if info.filename == record_info.filename + else content + ), + ) + for pyc in pycs: + info = zipfile.ZipInfo( + pyc.relative_to(extracted).as_posix(), + date_time=(1980, 1, 1, 0, 0, 0), + ) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + info.external_attr = 0o100644 << 16 + destination.writestr(info, pyc.read_bytes()) + os.replace(rebuilt, wheel) + return len(pycs) + + def _write_linux_runtime_lock(exported_lock: Path, destination: Path) -> None: """Evaluate uv markers for the VeFaaS Linux/x86_64 Python 3.12 target.""" environment: dict[str, str] = { diff --git a/frontend/service/studio_release_server/publisher.py b/frontend/service/studio_release_server/publisher.py index 02cd01b72..6a8aa0d0e 100644 --- a/frontend/service/studio_release_server/publisher.py +++ b/frontend/service/studio_release_server/publisher.py @@ -24,6 +24,7 @@ import re import shlex import shutil +import stat import subprocess import sys import tempfile @@ -71,7 +72,7 @@ _MAX_STUDIO_RELEASES = 50 _AGENTKIT_CLI_ARCHIVE = "agentkit-linux-x64.tar.gz" _AGENTKIT_CLI_ARCHIVE_SHA256 = ( - "4439d14b4be6ccb90f6eea896adf959ffef4ab4983f41e449d80c79d4cd95de3" + "8cc62477c582a7b9c3f460d2558bed45ee4c81c0169eabc228ddb19387268473" ) _STUDIO_RELEASE_CONTRACT = "agentkit-cli-v1" _STUDIO_RUNTIME_MANIFEST = "studio-runtime.json" @@ -812,7 +813,8 @@ def ensure_studio_bundle_agentkit_cli( ) try: with zipfile.ZipFile(bundle, "a") as archive: - archive.write( + _write_zip_file( + archive, cli_archive, _AGENTKIT_CLI_ARCHIVE, compress_type=zipfile.ZIP_STORED, @@ -988,6 +990,7 @@ def _build_local_requirements( veadk_wheel=built_wheels[0], dependency_sources=dependency_sources, environment=env, + optimize_cold_start=True, ) except ValueError as error: raise StudioPublisherError(str(error)) from error @@ -1009,6 +1012,12 @@ def _studio_run_script(*, thin: bool = False) -> str: else "python3 -m veadk.cli.studio_companion " f'--archive "$ROOT_DIR/{_AGENTKIT_CLI_ARCHIVE}"\n' ) + studio = ( + "python3 -m veadk.cli.studio_start " + '--provider "${CLOUD_PROVIDER:-${AGENTKIT_CLOUD_PROVIDER:-volcengine}}" ' + "--auth-mode frontend " + '--host "$HOST" --port "$PORT"\n' + ) return ( "#!/bin/bash\n" "set -ex\n" @@ -1021,11 +1030,22 @@ def _studio_run_script(*, thin: bool = False) -> str: "HOST=0.0.0.0\n" "PORT=${_FAAS_RUNTIME_PORT:-8000}\n" 'export PYTHONPATH="./site-packages${PYTHONPATH:+:$PYTHONPATH}"\n' - f"{companion}" - "exec python3 -m veadk.cli.cli studio " - '--provider "${CLOUD_PROVIDER:-${AGENTKIT_CLOUD_PROVIDER:-volcengine}}" ' - "--auth-mode frontend " - '--host "$HOST" --port "$PORT"\n' + 'trap \'kill "${COMPANION_PID:-}" "${STUDIO_PID:-}" ' + "2>/dev/null || true' INT TERM\n" + f"{companion.rstrip()} &\n" + "COMPANION_PID=$!\n" + f"{studio.rstrip()} &\n" + "STUDIO_PID=$!\n" + 'if ! wait "$COMPANION_PID"; then\n' + ' kill "$STUDIO_PID" 2>/dev/null || true\n' + ' wait "$STUDIO_PID" 2>/dev/null || true\n' + " exit 1\n" + "fi\n" + "COMPANION_PID=\n" + 'if wait "$STUDIO_PID"; then\n' + ' echo "studio_process_exited_unexpectedly" >&2\n' + "fi\n" + "exit 1\n" ) @@ -1346,11 +1366,33 @@ def _validate_public_wheel_license(path: Path, expected_name: str) -> None: ) +def _write_zip_file( + archive: zipfile.ZipFile, + path: Path, + relative: Path | str, + *, + compress_type: int, +) -> None: + relative_path = Path(relative) + info = zipfile.ZipInfo.from_file(path, relative_path) + info.create_system = 3 + mode = 0o755 if relative_path.as_posix() == "run.sh" else 0o644 + info.external_attr = (stat.S_IFREG | mode) << 16 + info.compress_type = compress_type + with path.open("rb") as input_file, archive.open(info, "w") as output_file: + shutil.copyfileobj(input_file, output_file) + + def _zip_directory(source: Path, destination: Path) -> None: with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive: for path in sorted(source.rglob("*")): if path.is_file(): - archive.write(path, path.relative_to(source)) + _write_zip_file( + archive, + path, + path.relative_to(source), + compress_type=zipfile.ZIP_DEFLATED, + ) def build_studio_release( diff --git a/frontend/service/studio_release_server/tos_store.py b/frontend/service/studio_release_server/tos_store.py index 675627ef8..015cdbaa8 100644 --- a/frontend/service/studio_release_server/tos_store.py +++ b/frontend/service/studio_release_server/tos_store.py @@ -19,6 +19,8 @@ import hashlib import json import os +import re +import urllib.parse import urllib.request from collections.abc import Callable from dataclasses import dataclass @@ -34,13 +36,14 @@ _IAM_CREDENTIAL_PATH = Path("/var/run/secrets/iam/credential") _MAX_DEPENDENCY_WHEEL_BYTES = 128 * 1024 * 1024 _PYPI_FILE_HOST = "https://files.pythonhosted.org" -_AGENTKIT_CLI_ARCHIVE_URL_PREFIX = ( - "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/" -) _PYPI_MIRROR_HOSTS = ( "https://pypi.tuna.tsinghua.edu.cn", "https://mirrors.aliyun.com/pypi", ) +_AGENTKIT_CLI_RELEASE_HOST = "agentkit-cli.tos-cn-beijing.volces.com" +_AGENTKIT_CLI_VERSION_PATTERN = re.compile( + r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" r"(?:[-+][0-9A-Za-z.-]+)?$" +) @dataclass(frozen=True) @@ -349,14 +352,35 @@ def _load_manifest(self, manifest: Path) -> tuple[tuple[str, str, str], ...]: raise ValueError( "Studio dependency manifest is invalid." ) from error - if filename == "agentkit-linux-x64.tar.gz" and not url.startswith( - _AGENTKIT_CLI_ARCHIVE_URL_PREFIX + if filename == "agentkit-linux-x64.tar.gz" and not ( + self._valid_agentkit_cli_url(url, filename) ): raise ValueError("Studio dependency manifest is invalid.") filenames.add(filename) dependencies.append((filename, url, sha256.lower())) return tuple(dependencies) + @staticmethod + def _valid_agentkit_cli_url(url: str, filename: str) -> bool: + """Validate one manifest-pinned CLI source without pinning its version.""" + try: + parsed = urllib.parse.urlsplit(url) + path_parts = parsed.path.split("/") + version = path_parts[1] if len(path_parts) == 3 else "" + return bool( + parsed.scheme == "https" + and parsed.netloc == _AGENTKIT_CLI_RELEASE_HOST + and parsed.username is None + and parsed.password is None + and parsed.port is None + and not parsed.query + and not parsed.fragment + and path_parts == ["", version, filename] + and _AGENTKIT_CLI_VERSION_PATTERN.fullmatch(version) + ) + except ValueError: + return False + def _cache_key(self, filename: str, sha256: str) -> str: prefix = self._settings.job_prefix.strip().strip("/") return f"{prefix}/dependency-cache/{sha256}/{filename}" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c638ba4bb..a5ec553ff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,7 @@ import { getSession, getStudioAccess, getRuntimeStudioToolCapabilities, + getRuntimeMcpCredentials, getRuntimes, listApps, listEnvironments, @@ -156,7 +157,10 @@ import { WorkspaceCreate, WorkspaceCreateIcon } from "./create/WorkspaceCreate"; import { CodePackageCreate } from "./create/CodePackageCreate"; import { MigrationWorkspace } from "./migrations/MigrationWorkspace"; import type { AgentDraft } from "./create/types"; -import { configuredMcpEnvKeys } from "./create/mcpAuth"; +import { + configuredMcpEnvKeys, + hydrateMcpCredentialValues, +} from "./create/mcpAuth"; import { hydrateRuntimeModelSelection, isRuntimeModelSelectionEnv, @@ -2626,8 +2630,7 @@ export default function App() { })); } updateDeploymentTask(linkedTask); - openDeploymentDetail(linkedTask); - }, [editingDraftId, flushPendingWorkspaceDraft, openDeploymentDetail, updateDeploymentTask]); + }, [editingDraftId, flushPendingWorkspaceDraft, updateDeploymentTask]); const finishDeployment = useCallback( async (result: DeployResult) => { @@ -7346,11 +7349,33 @@ export default function App() { hydratedDraft, arkModelIds, ); + let editorDraft = classifiedDraft; + if (configuredMcpEnvKeys(classifiedDraft).length > 0) { + try { + const credentials = await getRuntimeMcpCredentials({ + runtimeId: capability.runtime.runtimeId, + region: capability.runtime.region, + appName: capability.agent.appName, + etag: capability.etag, + }); + editorDraft = hydrateMcpCredentialValues( + classifiedDraft, + credentials, + ); + } catch (credentialError) { + setError( + credentialError instanceof Error + ? credentialError.message + : appText("errors.runtimeDeploymentConfigUnavailable"), + ); + return; + } + } exitAgentDetailContext(); - setImportedDraft(classifiedDraft); + setImportedDraft(editorDraft); setCustomCreateMode("custom"); setCustomCreationSurface( - classifiedDraft.dynamicAgentDelegation === true + editorDraft.dynamicAgentDelegation === true ? "vulcan" : "traditional", ); @@ -7373,7 +7398,7 @@ export default function App() { capability.editMode === "source-preserving" ? "source-preserving" : "regenerate", - configuredMcpEnvKeys: configuredMcpEnvKeys(classifiedDraft), + configuredMcpEnvKeys: configuredMcpEnvKeys(editorDraft), configuredRuntimeEnvKeys: capability.runtime.configuredEnvKeys, }); diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 8fbd6d21c..c63033d03 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -21,6 +21,7 @@ import { DeploymentStatusUnconfirmedError, isDeploymentAbortError, isDeploymentStatusUnconfirmedError, + pollDeploymentRecovery, } from "./deploymentStatus"; import { DEFAULT_REQUEST_TIMEOUT_MS, @@ -30,6 +31,7 @@ import { import type { AgentProject } from "../create/project"; import type { AgentDraft, + McpCredentialValue, NetworkConfig, SelectedSkill, } from "../create/types"; @@ -3225,6 +3227,16 @@ interface DeployFrame extends Partial { phase?: string; } +class DeploymentRecoveryHttpError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "DeploymentRecoveryHttpError"; + } +} + const deploymentControllers = new Map(); function parseGithubCicdErrorDetail(detail: unknown): GithubCicdPipelineErrorDetail | null { @@ -3552,8 +3564,60 @@ export async function deployAgentkitProject( deploymentControllers.delete(taskId); } }; + const recoverFinal = async (cause?: unknown): Promise => { + if (isDeploymentAbortError(cause)) throw cause; + if ( + !taskId || + !opts?.runtimeId || + typeof opts.baseRuntimeVersion !== "number" + ) { + throw new DeploymentStatusUnconfirmedError({ taskId, cause }); + } + const recovered = await pollDeploymentRecovery({ + signal: controller?.signal, + shouldRetry: (error) => + !(error instanceof DeploymentRecoveryHttpError) || + error.status === 404 || + error.status === 408 || + error.status === 429 || + error.status >= 500, + load: async (signal) => { + const statusResponse = await apiFetch( + "/web/deploy-agentkit/status", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal, + body: JSON.stringify({ + taskId, + runtimeId: opts.runtimeId, + runtimeName: opts.runtimeName, + appName: opts.appName ?? name, + region: config.region, + projectName: config.projectName, + baseRuntimeVersion: opts.baseRuntimeVersion, + }), + }, + {}, + 30_000, + ); + if (!statusResponse.ok) { + throw new DeploymentRecoveryHttpError( + statusResponse.status, + await httpErrorMessage(statusResponse, adkT("client.deploymentFailed")), + ); + } + return statusResponse.json() as Promise; + }, + }); + if (!recovered) { + throw new DeploymentStatusUnconfirmedError({ taskId, cause }); + } + return recovered; + }; - let res: Response; + let res: Response | null = null; + let final: DeployFrame | null = null; try { const migrationSource = Boolean(opts?.migrationTaskId); opts?.onStage?.({ @@ -3612,34 +3676,45 @@ export async function deployAgentkitProject( pct: 100, }); } catch (error) { - clearController(); - if (isDeploymentAbortError(error)) throw error; - throw new DeploymentStatusUnconfirmedError({ taskId, cause: error }); + try { + final = await recoverFinal(error); + } finally { + clearController(); + } } - if (!res.ok) { + if (res && !res.ok) { const detail = await httpErrorMessage(res, adkT("client.deploymentFailed")); clearController(); throw new Error(detail); } - let final: DeployFrame | null = null; - try { - for await (const raw of parseSSE(res)) { - const ev = raw as DeployFrame & DeployStage; - if (ev && ev.done) { - final = ev; - break; + if (res) { + try { + for await (const raw of parseSSE(res)) { + const ev = raw as DeployFrame & DeployStage; + if (ev && ev.done) { + final = ev; + break; + } + if (ev && ev.message) opts?.onStage?.(ev); + } + } catch (error) { + try { + final = await recoverFinal(error); + } finally { + clearController(); } - if (ev && ev.message) opts?.onStage?.(ev); } - } catch (error) { + } + if (!final) { + try { + final = await recoverFinal(); + } finally { + clearController(); + } + } else { clearController(); - if (isDeploymentAbortError(error)) throw error; - throw new DeploymentStatusUnconfirmedError({ taskId, cause: error }); } - clearController(); - - if (!final) throw new DeploymentStatusUnconfirmedError({ taskId }); if (!final.success) { const error = new Error(final.error || adkT("client.deploymentFailed")); if (isDeploymentStatusUnconfirmedError(error)) { @@ -4588,6 +4663,57 @@ export interface RuntimeUpdateCapability { } | null; } +/** Fetch the exact MCP credentials for one authorized update snapshot. */ +export async function getRuntimeMcpCredentials({ + runtimeId, + region, + appName, + etag, + signal, +}: { + runtimeId: string; + region: string; + appName: string; + etag: string; + signal?: AbortSignal; +}): Promise { + const res = await apiFetch("/web/runtime-mcp-credentials", { + method: "POST", + cache: "no-store", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ runtimeId, region, appName, etag }), + }); + if (!res.ok) { + throw new Error( + await httpErrorMessage(res, adkT("client.loadMcpCredentialsFailed")), + ); + } + const payload = (await res.json().catch(() => null)) as { + credentials?: unknown; + } | null; + if (!Array.isArray(payload?.credentials)) { + throw new Error(adkT("client.invalidMcpCredentials")); + } + return payload.credentials.map((item) => { + if (!item || typeof item !== "object") { + throw new Error(adkT("client.invalidMcpCredentials")); + } + const raw = item as Record; + const credential = { + agentName: raw.agentName, + name: raw.name, + url: raw.url, + authTokenEnv: raw.authTokenEnv, + value: raw.value, + }; + if (Object.values(credential).some((value) => typeof value !== "string")) { + throw new Error(adkT("client.invalidMcpCredentials")); + } + return credential as McpCredentialValue; + }); +} + interface RuntimeUpdateCapabilityRequest { runtimeId: string; region: string; @@ -4930,6 +5056,7 @@ export interface GeneratedAgentDraftResult { } const GENERATED_AGENT_DRAFT_TIMEOUT_MS = 190_000; +const GENERATED_AGENT_TEST_RUN_TIMEOUT_MS = 120_000; export async function generateAgentDraftFromRequirement( requirement: string, @@ -4963,16 +5090,21 @@ export async function createGeneratedAgentTestRun( }>; }, ): Promise { - const res = await apiFetch("/web/generated-agent-test-runs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - draft, - runtimeId: runtime?.runtimeId, - runtimeRegion: runtime?.region, - mcpCredentialReuses: runtime?.mcpCredentialReuses, - }), - }); + const res = await apiFetch( + "/web/generated-agent-test-runs", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + draft, + runtimeId: runtime?.runtimeId, + runtimeRegion: runtime?.region, + mcpCredentialReuses: runtime?.mcpCredentialReuses, + }), + }, + {}, + GENERATED_AGENT_TEST_RUN_TIMEOUT_MS, + ); if (!res.ok) { throw new Error(await httpErrorMessage(res, adkT("client.createDebugRunFailed"))); } diff --git a/frontend/src/adk/deploymentStatus.ts b/frontend/src/adk/deploymentStatus.ts index 81c2a02f1..5d2cd69e4 100644 --- a/frontend/src/adk/deploymentStatus.ts +++ b/frontend/src/adk/deploymentStatus.ts @@ -30,3 +30,50 @@ export function isDeploymentAbortError(error: unknown): boolean { error.name === "AbortError", ); } + +export async function pollDeploymentRecovery({ + load, + signal, + timeoutMs = 30 * 60_000, + intervalMs = 2_000, + shouldRetry = () => true, +}: { + load: (signal?: AbortSignal) => Promise; + signal?: AbortSignal; + timeoutMs?: number; + intervalMs?: number; + shouldRetry?: (error: unknown) => boolean; +}): Promise { + if (signal?.aborted) { + throw signal.reason ?? new DOMException("Aborted", "AbortError"); + } + const deadline = Date.now() + Math.max(0, timeoutMs); + while (!signal?.aborted && Date.now() <= deadline) { + try { + const status = await load(signal); + if (status.done) return status; + } catch (error) { + if (signal?.aborted || isDeploymentAbortError(error)) throw error; + if (!shouldRetry(error)) throw error; + // Instance replacement can also interrupt an individual status read. + // Keep polling until the bounded recovery window expires. + } + if (signal?.aborted) { + throw signal.reason ?? new DOMException("Aborted", "AbortError"); + } + if (intervalMs <= 0) continue; + await new Promise((resolve, reject) => { + const onAbort = () => { + globalThis.clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + reject(signal?.reason ?? new DOMException("Aborted", "AbortError")); + }; + const timer = globalThis.setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, intervalMs); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + return null; +} diff --git a/frontend/src/create/CustomCreate.css b/frontend/src/create/CustomCreate.css index a8da4f158..f70d4f472 100644 --- a/frontend/src/create/CustomCreate.css +++ b/frontend/src/create/CustomCreate.css @@ -3752,50 +3752,39 @@ line-height: 1.5; color: hsl(var(--muted-foreground)); } -.cw-mcp-auth-state { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-height: 32px; - padding: 7px 10px; - border: 1px solid hsl(var(--border)); - border-radius: 8px; - background: hsl(var(--muted)); - color: hsl(var(--muted-foreground)); - font-size: 12px; - line-height: 1.45; +.cw-mcp-token-field { + position: relative; } -.cw-mcp-auth-state button { - flex: 0 0 auto; - min-height: 28px; - padding: 0 9px; - border: 1px solid hsl(var(--border)); +.cw-mcp-token-field .cw-input { + padding-right: 40px; +} +.cw-mcp-token-toggle { + position: absolute; + top: 50%; + right: 4px; + display: inline-grid; + width: 28px; + height: 28px; + padding: 0; + border: 0; border-radius: 6px; - background: hsl(var(--panel)); - color: hsl(var(--foreground)); + background: transparent; + color: hsl(var(--muted-foreground)); cursor: pointer; - font: inherit; + place-items: center; + transform: translateY(-50%); } -.cw-mcp-auth-state button:hover { - background: hsl(var(--accent)); +.cw-mcp-token-toggle:hover { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); } -.cw-mcp-auth-state button:focus-visible { +.cw-mcp-token-toggle:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 1px; } -.cw-mcp-auth-state.is-warning { - align-items: flex-start; - border-color: hsl(var(--destructive) / 0.35); - background: hsl(var(--destructive) / 0.06); - color: hsl(var(--foreground)); -} -.cw-mcp-auth-actions { - display: flex; - flex: 0 0 auto; - flex-wrap: wrap; - justify-content: flex-end; - gap: 6px; +.cw-mcp-token-toggle svg { + width: 16px; + height: 16px; } .cw-mcp-warning { display: flex; diff --git a/frontend/src/create/CustomCreate.tsx b/frontend/src/create/CustomCreate.tsx index db1a0f364..a8124b0b5 100644 --- a/frontend/src/create/CustomCreate.tsx +++ b/frontend/src/create/CustomCreate.tsx @@ -79,6 +79,7 @@ import { firstMissingRuntimeEnv, firstInvalidRuntimeEnv, runtimeEnvConfiguration, + runtimeDebugEnvVars, runtimeEnvJsonError, runtimeEnvVars, type RuntimeEnvConfiguration, @@ -94,18 +95,12 @@ import { import { localPickerMatches } from "./localPickerSearch"; import { draftToYaml } from "./configYaml"; import { - confirmMcpCredentialReuse, - clearMcpConfiguredAuth, deploymentMcpSecretValues, type McpConfigurationConflict, mcpAuthTokenInputValue, - mcpCredentialActionRequired, mcpConfigurationConflict, - mcpCredentialReuseValues, mcpUrlNeedsPathWarning, prepareMcpAuth, - removeMcpCredentialForChangedUrl, - replaceMcpCredentialForChangedUrl, removedConfiguredMcpEnvKeys, sourcePreservingMcpSecretValues, updateMcpAuthTokenInput, @@ -2118,6 +2113,34 @@ function VikingMemorySelect({ * (http / stdio) and shows the matching fields. http -> url + optional * bearer token; stdio -> command + space-separated args. Optional name. * ---------------------------------------------------------------- */ +function McpTokenVisibilityIcon({ hidden }: { hidden: boolean }) { + return ( + + ); +} + function McpToolEditor({ tools, conflict, @@ -2131,11 +2154,20 @@ function McpToolEditor({ }) { const { t } = useTranslation("create"); const conflictErrorId = useId(); + const [revealedTokenIndex, setRevealedTokenIndex] = useState( + null, + ); const visibleConflict = showConflict ? conflict : null; const update = (i: number, p: Partial) => onChange(tools.map((tool, idx) => (idx === i ? { ...tool, ...p } : tool))); - const remove = (i: number) => onChange(tools.filter((_, idx) => idx !== i)); + const remove = (i: number) => { + setRevealedTokenIndex((current) => { + if (current == null || current < i) return current; + return current === i ? null : current - 1; + }); + onChange(tools.filter((_, idx) => idx !== i)); + }; const add = () => onChange([...tools, { name: "", transport: "http", url: "" }]); @@ -2233,119 +2265,49 @@ function McpToolEditor({

)} - - onChange( - tools.map((tool, index) => - index === i - ? updateMcpAuthTokenInput(tool, e.target.value) - : tool, - ), - ) - } - /> - {tool.credentialUpdate === "pending" && ( -
+ + onChange( + tools.map((tool, index) => + index === i + ? updateMcpAuthTokenInput(tool, e.target.value) + : tool, + ), + ) + } + /> + - - -
- - )} - {tool.credentialUpdate === "reuse" && ( -
- {t("traditional.mcp.reuseHint")} - -
- )} - {tool.credentialConfigured && - !tool.authToken && - !tool.credentialUpdate && ( -
- {t("traditional.mcp.credentialConfigured")} - -
- )} +