From f14d5faafdb6e76dd6616a951cabe28ba3708075 Mon Sep 17 00:00:00 2001 From: xiayu Date: Thu, 13 Aug 2026 18:44:58 +0800 Subject: [PATCH 01/10] chore(release): prepare public ksadk 0.8.1 candidate --- .github/pull_request_template.md | 2 +- .github/workflows/ci.yml | 53 +- .github/workflows/publish-pypi.yml | 4 +- .github/workflows/release-check.yml | 6 +- .gitignore | 7 + CHANGELOG.md | 74 + CONTRIBUTING.md | 6 +- MANIFEST.in | 2 + Makefile | 54 +- README.en.md | 9 + README.md | 9 + README.zh-CN.md | 9 + docs-site/content/docs/cli/index.en.mdx | 79 +- docs-site/content/docs/cli/index.mdx | 72 +- .../guides/agentkit-local-studio.en.mdx | 81 + .../guides/agentkit-local-studio.mdx | 81 + .../framework/guides/build-and-package.en.mdx | 39 +- .../framework/guides/build-and-package.mdx | 23 +- .../framework/guides/memory-knowledge.en.mdx | 13 + .../framework/guides/memory-knowledge.mdx | 12 + .../content/docs/framework/guides/meta.json | 1 + .../guides/observability-tracing.en.mdx | 83 +- .../guides/observability-tracing.mdx | 89 +- .../framework/guides/web-ui-source.en.mdx | 51 +- .../docs/framework/guides/web-ui-source.mdx | 50 +- docs-site/content/docs/framework/index.en.mdx | 1 + docs-site/content/docs/framework/index.mdx | 1 + docs-site/content/docs/framework/meta.json | 1 + .../references/environment-variables.en.mdx | 32 +- .../docs/references/environment-variables.mdx | 32 +- ...30\351\207\217\345\217\202\350\200\203.md" | 87 +- export-manifest.json | 93 +- ksadk/a2a/__init__.py | 2 + ksadk/a2a/bootstrap.py | 2 - ksadk/a2a/card.py | 117 +- ksadk/a2a/card_builder.py | 64 - ksadk/a2a/client.py | 309 - ksadk/a2a/control_plane.py | 240 +- ksadk/a2a/executor.py | 127 +- ksadk/a2a/routes.py | 17 +- ksadk/a2a/server.py | 9 +- ksadk/a2a/service_env.py | 33 + ksadk/a2a/space_client.py | 63 +- ksadk/a2a_card.py | 101 + ksadk/agui/agent.py | 47 +- ksadk/agui/config.py | 16 +- ksadk/agui/routes.py | 22 +- ksadk/api/client.py | 18 +- ksadk/api/environment.py | 27 + ksadk/builders/code_builder.py | 115 +- ksadk/builders/container_builder.py | 108 +- ksadk/builders/framework_requirements.py | 3 +- ksadk/builders/ks3_uploader.py | 4 +- ksadk/builders/managed_runtime_builder.py | 40 +- ksadk/cli/__init__.py | 8 + ksadk/cli/cmd_a2a.py | 49 +- ksadk/cli/cmd_create.py | 39 +- ksadk/cli/cmd_deploy.py | 19 +- ksadk/cli/cmd_eval.py | 386 + ksadk/cli/cmd_hermes.py | 199 +- ksadk/cli/cmd_launch.py | 12 + ksadk/cli/cmd_openclaw.py | 128 +- ksadk/cli/cmd_run.py | 96 +- ksadk/cli/cmd_studio.py | 110 + ksadk/cli/cmd_web.py | 11 +- ksadk/cli/dry_run.py | 3 +- ksadk/cli/env_options.py | 83 + ksadk/cli/global_options.py | 4 +- ksadk/cli/network_options.py | 12 +- ksadk/cli/runtime_bootstrap.py | 51 + ksadk/cli/ui.py | 6 + ksadk/cli/workflow_common.py | 10 +- ksadk/codex/__init__.py | 6 +- ksadk/codex/client.py | 698 +- ksadk/codex/phase.py | 2 +- ksadk/codex/runtime.py | 547 +- ksadk/common/kop_client.py | 232 + ksadk/configs/__init__.py | 4 +- ksadk/configs/env_registry.py | 213 +- ksadk/configs/global_config.py | 2 +- ksadk/configs/settings.py | 113 +- ksadk/conversations/__init__.py | 179 +- ksadk/conversations/runtime_input.py | 10 +- ksadk/conversations/runtime_observability.py | 20 +- ksadk/conversations/runtime_preparation.py | 8 + ksadk/conversations/runtime_resume.py | 84 +- ksadk/conversations/runtime_streaming.py | 197 +- ksadk/deployment/managed_runtime.py | 33 + ksadk/deployment/manager.py | 32 +- ksadk/deployment/providers/serverless.py | 116 +- ksadk/evaluation/__init__.py | 84 + ksadk/evaluation/a2a_adapter.py | 444 ++ ksadk/evaluation/adapters.py | 54 + ksadk/evaluation/contracts.py | 409 + ksadk/evaluation/evalset.py | 351 + ksadk/evaluation/evaluators.py | 461 ++ ksadk/evaluation/executor.py | 118 + ksadk/evaluation/storage.py | 68 + ksadk/evaluation/target.py | 73 + ksadk/events/runtime_event.py | 9 + ksadk/harness/__init__.py | 5 +- ksadk/harness/app.py | 237 +- ksadk/harness/config.py | 2 +- ksadk/harness/runner.py | 211 - ksadk/harness/runtime.py | 364 + ksadk/hermes_terminal.py | 5 + ksadk/knowledge_base/client.py | 19 +- ksadk/managed_a2a_card.py | 160 + ksadk/managed_runtime.py | 25 +- ksadk/memory/adk/backends/sdk_ltm_backend.py | 13 +- ksadk/memory/adk/long_term_memory.py | 5 +- ksadk/memory/service.py | 12 +- ksadk/memory/tool.py | 4 + ksadk/model_proxy/config.py | 23 +- ksadk/model_proxy/namespace.py | 30 +- ksadk/model_proxy/server.py | 151 +- ksadk/model_proxy/transform.py | 46 +- ksadk/runners/adk_runner.py | 34 +- ksadk/runners/base_runner.py | 31 +- ksadk/runners/codex_runner.py | 132 - ksadk/runners/factory.py | 7 +- ksadk/runners/langgraph_runner.py | 108 +- ksadk/runners/utils/__init__.py | 10 - ksadk/runners/utils/langfuse.py | 230 - ksadk/runners/utils/loader.py | 16 + ksadk/runtime/__init__.py | 108 +- ksadk/runtime/adapter.py | 97 +- ksadk/runtime/conversation_execution.py | 553 ++ ksadk/runtime/executor.py | 251 + ksadk/runtime/factory.py | 156 + ksadk/runtime/framework_adapters.py | 18 +- ksadk/runtime/launch.py | 48 + ksadk/runtime/preprocessing.py | 41 +- ksadk/runtime/runner_adapter.py | 215 +- ksadk/runtime/runner_loading.py | 36 + ksadk/sandbox/backends/e2b.py | 2 +- ksadk/server/__init__.py | 43 +- ksadk/server/app.py | 510 +- ksadk/server/composition.py | 30 +- ksadk/server/factory.py | 294 +- ksadk/server/routes/common.py | 61 +- ksadk/server/routes/control.py | 22 +- ksadk/server/routes/misc.py | 43 +- ksadk/server/routes/models.py | 20 +- ksadk/server/routes/openai_compat.py | 68 +- ksadk/server/routes/projection.py | 83 +- ksadk/server/routes/run.py | 713 +- ksadk/server/routes/sessions.py | 75 +- ksadk/server/routes/streaming.py | 52 +- ksadk/server/routes/workspace.py | 75 +- ksadk/skills/runtime/backends/e2b.py | 2 +- ksadk/skills/service_client.py | 16 +- ksadk/studio/__init__.py | 5 + ksadk/studio/agent_avatar_assets.py | 120 + ksadk/studio/agent_lifecycle.py | 51 + ksadk/studio/api.py | 1047 +++ ksadk/studio/api_catalog_routes.py | 324 + ksadk/studio/api_contracts.py | 177 + ksadk/studio/api_helpers.py | 132 + ksadk/studio/authoring.py | 519 ++ ksadk/studio/authoring_coordinator.py | 285 + ksadk/studio/builder.py | 320 + ksadk/studio/capabilities.py | 351 + ksadk/studio/cloud.py | 278 + ksadk/studio/codex_agent_service.py | 583 ++ ksadk/studio/codex_builder.py | 324 + ksadk/studio/codex_manifest.py | 278 + ksadk/studio/codex_run.py | 352 + ksadk/studio/compiler.py | 189 + ksadk/studio/contracts.py | 644 ++ ksadk/studio/errors.py | 47 + ksadk/studio/evaluation.py | 192 + ksadk/studio/event_store.py | 284 + ksadk/studio/framework_run.py | 114 + ksadk/studio/identifiers.py | 33 + ksadk/studio/mcp_runtime.py | 229 + ksadk/studio/model_client.py | 543 ++ ksadk/studio/model_profile_service.py | 214 + ksadk/studio/operations.py | 243 + ksadk/studio/otel_trace.py | 754 ++ ksadk/studio/pagination.py | 182 + ksadk/studio/python_tool_inspection.py | 183 + ksadk/studio/react-ui/components.json | 18 + ksadk/studio/react-ui/index.html | 12 + ksadk/studio/react-ui/package-lock.json | 5948 +++++++++++++++ ksadk/studio/react-ui/package.json | 59 + ksadk/studio/react-ui/postcss.config.js | 5 + ksadk/studio/react-ui/src/App.tsx | 364 + ksadk/studio/react-ui/src/api.test.mjs | 150 + ksadk/studio/react-ui/src/api.ts | 103 + .../react-ui/src/approvalModes.test.mjs | 27 + ksadk/studio/react-ui/src/approvalModes.ts | 41 + .../studio/react-ui/src/chatProtocol.test.mjs | 241 + ksadk/studio/react-ui/src/chatProtocol.ts | 744 ++ .../src/components/A2UIRenderer.test.tsx | 127 + .../react-ui/src/components/A2UIRenderer.tsx | 230 + .../src/components/AgentAppearanceEditor.tsx | 253 + .../react-ui/src/components/AgentAvatar.tsx | 48 + .../react-ui/src/components/ChatRunPanel.tsx | 301 + .../react-ui/src/components/ChatWorkspace.tsx | 1191 +++ .../components/ComposerActionMenu.test.tsx | 37 + .../src/components/ComposerActionMenu.tsx | 115 + .../react-ui/src/components/ConfirmDialog.tsx | 49 + .../studio/react-ui/src/components/Drawer.tsx | 48 + .../src/components/NavigationRail.test.tsx | 79 + .../src/components/NavigationRail.tsx | 200 + .../src/components/PythonToolExample.test.tsx | 28 + .../src/components/PythonToolExample.tsx | 58 + .../src/components/RuntimeModeBar.test.tsx | 51 + .../src/components/RuntimeModeBar.tsx | 96 + .../src/components/SettingsOverlay.tsx | 266 + .../src/components/SkillFileBrowser.test.tsx | 75 + .../src/components/SkillFileBrowser.tsx | 299 + .../studio/react-ui/src/components/Toast.tsx | 46 + .../src/components/ui/CodeViewer.test.tsx | 70 + .../react-ui/src/components/ui/CodeViewer.tsx | 81 + .../src/components/ui/FileDropzone.test.tsx | 41 + .../src/components/ui/FileDropzone.tsx | 102 + .../src/components/ui/FormField.test.tsx | 32 + .../react-ui/src/components/ui/FormField.tsx | 91 + .../components/ui/GeneratedIdField.test.tsx | 25 + .../src/components/ui/GeneratedIdField.tsx | 50 + .../src/components/ui/MarkdownPreview.tsx | 66 + .../src/components/ui/PrismRenderer.tsx | 94 + .../components/ui/StudioDataTable.test.tsx | 104 + .../src/components/ui/StudioDataTable.tsx | 212 + .../src/components/ui/StudioDialog.test.tsx | 81 + .../src/components/ui/StudioDialog.tsx | 240 + .../components/ui/StudioMultiSelect.test.tsx | 52 + .../src/components/ui/StudioMultiSelect.tsx | 136 + .../src/components/ui/StudioScrollArea.tsx | 27 + .../src/components/ui/StudioSelect.test.tsx | 43 + .../src/components/ui/StudioSelect.tsx | 67 + .../react-ui/src/components/ui/badge.tsx | 23 + .../react-ui/src/components/ui/button.tsx | 35 + .../react-ui/src/components/ui/card.tsx | 22 + .../react-ui/src/components/ui/input.tsx | 12 + .../react-ui/src/components/ui/separator.tsx | 8 + .../react-ui/src/components/ui/tabs.tsx | 20 + .../react-ui/src/composerActions.test.ts | 87 + ksadk/studio/react-ui/src/composerActions.ts | 127 + .../createPage.component-contract.test.mjs | 23 + ksadk/studio/react-ui/src/index.css | 1546 ++++ ksadk/studio/react-ui/src/lib/formErrors.ts | 72 + ksadk/studio/react-ui/src/lib/generatedId.ts | 5 + ksadk/studio/react-ui/src/lib/utils.test.ts | 8 + ksadk/studio/react-ui/src/lib/utils.ts | 6 + ksadk/studio/react-ui/src/main.tsx | 23 + .../react-ui/src/pages/AgentDetailPage.tsx | 274 + .../react-ui/src/pages/AgentEditor.test.tsx | 92 + .../studio/react-ui/src/pages/AgentEditor.tsx | 424 ++ .../react-ui/src/pages/AgentsPage.test.tsx | 49 + .../studio/react-ui/src/pages/AgentsPage.tsx | 236 + .../studio/react-ui/src/pages/BuildsPage.tsx | 188 + .../react-ui/src/pages/CreatePage.test.tsx | 139 + .../studio/react-ui/src/pages/CreatePage.tsx | 1215 +++ .../react-ui/src/pages/DeploymentsPage.tsx | 20 + .../react-ui/src/pages/ObservabilityPage.tsx | 972 +++ .../src/pages/OrchestrationPage.test.tsx | 60 + .../react-ui/src/pages/OrchestrationPage.tsx | 336 + .../react-ui/src/pages/ResourcesPage.tsx | 1459 ++++ .../src/pages/RuntimeResourcesPage.tsx | 138 + ksadk/studio/react-ui/src/responsive.css | 642 ++ .../react-ui/src/responsiveViewport.test.mjs | 20 + .../studio/react-ui/src/responsiveViewport.ts | 8 + .../react-ui/src/schemas/agentForms.test.ts | 53 + .../studio/react-ui/src/schemas/agentForms.ts | 58 + .../src/schemas/resourceForms.test.ts | 111 + .../react-ui/src/schemas/resourceForms.ts | 93 + .../react-ui/src/skillBatchImport.test.mjs | 86 + ksadk/studio/react-ui/src/skillBatchImport.ts | 84 + ksadk/studio/react-ui/src/studio.css | 6756 +++++++++++++++++ .../studio/react-ui/src/studioTheme.test.mjs | 24 + ksadk/studio/react-ui/src/studioTheme.ts | 41 + ksadk/studio/react-ui/src/test/setup.ts | 23 + ksadk/studio/react-ui/src/theme.css | 163 + ksadk/studio/react-ui/src/useStudioTheme.ts | 39 + .../react-ui/src/useStudioViewportMode.ts | 19 + ksadk/studio/react-ui/tsconfig.json | 19 + ksadk/studio/react-ui/vite.config.ts | 42 + ksadk/studio/react-ui/vitest.config.ts | 17 + ksadk/studio/repository.py | 306 + ksadk/studio/resource_catalog.py | 1217 +++ ksadk/studio/run_service.py | 707 ++ ksadk/studio/runtime_catalog.py | 74 + ksadk/studio/runtime_source.py | 339 + ksadk/studio/service.py | 1141 +++ ksadk/studio/shared_web.py | 994 +++ ksadk/studio/skill_discovery.py | 628 ++ ksadk/studio/templates.py | 630 ++ ksadk/studio/validator.py | 246 + ksadk/studio/workspace.py | 119 + ksadk/tools/gateway.py | 9 + ksadk/toolsets/__init__.py | 28 +- ksadk/toolsets/a2a.py | 426 ++ ksadk/tracing/__init__.py | 40 +- ksadk/tracing/exporters/langfuse_exporter.py | 460 -- ksadk/tracing/setup.py | 589 +- ksadk/tracing/span_utils.py | 44 - ksadk/version.py | 2 +- .../memory_backend/registry.py | 112 +- .../workspace_files/router.py | 10 +- pyproject.toml | 30 +- scripts/audit_release_artifacts.py | 1 - scripts/build_alias_distribution.py | 1 - scripts/check_approval_record.py | 16 +- scripts/check_publication_state.py | 1 - scripts/check_release_version.py | 30 +- scripts/generate_public_assets.py | 9 +- scripts/open_source_audit.py | 76 +- scripts/prepare_ksadk_python_export.py | 1 + scripts/prepare_ksadk_web_export.py | 16 +- tests/cli/test_cmd_create_codex.py | 2 + tests/conftest.py | 10 + tests/runners/test_adapter_contract.py | 350 + tests/runners/test_adk_runner.py | 46 - tests/runners/test_codex_runner.py | 120 - tests/test_a2a_agent_card.py | 124 - tests/test_a2a_text_replacement.py | 85 - tests/test_config_env_registry.py | 4 +- tests/test_managed_runtime_builder.py | 83 +- tests/test_public_release_positioning.py | 34 +- tests/test_runtime_common_packaging.py | 234 +- tests/test_security_boundaries.py | 77 - tests/test_tracing_setup_otlp.py | 389 +- uv.lock | 252 +- 326 files changed, 56044 insertions(+), 5850 deletions(-) create mode 100644 docs-site/content/docs/framework/guides/agentkit-local-studio.en.mdx create mode 100644 docs-site/content/docs/framework/guides/agentkit-local-studio.mdx delete mode 100644 ksadk/a2a/card_builder.py delete mode 100644 ksadk/a2a/client.py create mode 100644 ksadk/a2a/service_env.py create mode 100644 ksadk/a2a_card.py create mode 100644 ksadk/api/environment.py create mode 100644 ksadk/cli/cmd_eval.py create mode 100644 ksadk/cli/cmd_studio.py create mode 100644 ksadk/cli/runtime_bootstrap.py create mode 100644 ksadk/common/kop_client.py create mode 100644 ksadk/deployment/managed_runtime.py create mode 100644 ksadk/evaluation/__init__.py create mode 100644 ksadk/evaluation/a2a_adapter.py create mode 100644 ksadk/evaluation/adapters.py create mode 100644 ksadk/evaluation/contracts.py create mode 100644 ksadk/evaluation/evalset.py create mode 100644 ksadk/evaluation/evaluators.py create mode 100644 ksadk/evaluation/executor.py create mode 100644 ksadk/evaluation/storage.py create mode 100644 ksadk/evaluation/target.py delete mode 100644 ksadk/harness/runner.py create mode 100644 ksadk/harness/runtime.py create mode 100644 ksadk/managed_a2a_card.py delete mode 100644 ksadk/runners/codex_runner.py delete mode 100644 ksadk/runners/utils/langfuse.py create mode 100644 ksadk/runtime/conversation_execution.py create mode 100644 ksadk/runtime/executor.py create mode 100644 ksadk/runtime/factory.py create mode 100644 ksadk/runtime/launch.py create mode 100644 ksadk/runtime/runner_loading.py create mode 100644 ksadk/studio/__init__.py create mode 100644 ksadk/studio/agent_avatar_assets.py create mode 100644 ksadk/studio/agent_lifecycle.py create mode 100644 ksadk/studio/api.py create mode 100644 ksadk/studio/api_catalog_routes.py create mode 100644 ksadk/studio/api_contracts.py create mode 100644 ksadk/studio/api_helpers.py create mode 100644 ksadk/studio/authoring.py create mode 100644 ksadk/studio/authoring_coordinator.py create mode 100644 ksadk/studio/builder.py create mode 100644 ksadk/studio/capabilities.py create mode 100644 ksadk/studio/cloud.py create mode 100644 ksadk/studio/codex_agent_service.py create mode 100644 ksadk/studio/codex_builder.py create mode 100644 ksadk/studio/codex_manifest.py create mode 100644 ksadk/studio/codex_run.py create mode 100644 ksadk/studio/compiler.py create mode 100644 ksadk/studio/contracts.py create mode 100644 ksadk/studio/errors.py create mode 100644 ksadk/studio/evaluation.py create mode 100644 ksadk/studio/event_store.py create mode 100644 ksadk/studio/framework_run.py create mode 100644 ksadk/studio/identifiers.py create mode 100644 ksadk/studio/mcp_runtime.py create mode 100644 ksadk/studio/model_client.py create mode 100644 ksadk/studio/model_profile_service.py create mode 100644 ksadk/studio/operations.py create mode 100644 ksadk/studio/otel_trace.py create mode 100644 ksadk/studio/pagination.py create mode 100644 ksadk/studio/python_tool_inspection.py create mode 100644 ksadk/studio/react-ui/components.json create mode 100644 ksadk/studio/react-ui/index.html create mode 100644 ksadk/studio/react-ui/package-lock.json create mode 100644 ksadk/studio/react-ui/package.json create mode 100644 ksadk/studio/react-ui/postcss.config.js create mode 100644 ksadk/studio/react-ui/src/App.tsx create mode 100644 ksadk/studio/react-ui/src/api.test.mjs create mode 100644 ksadk/studio/react-ui/src/api.ts create mode 100644 ksadk/studio/react-ui/src/approvalModes.test.mjs create mode 100644 ksadk/studio/react-ui/src/approvalModes.ts create mode 100644 ksadk/studio/react-ui/src/chatProtocol.test.mjs create mode 100644 ksadk/studio/react-ui/src/chatProtocol.ts create mode 100644 ksadk/studio/react-ui/src/components/A2UIRenderer.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/A2UIRenderer.tsx create mode 100644 ksadk/studio/react-ui/src/components/AgentAppearanceEditor.tsx create mode 100644 ksadk/studio/react-ui/src/components/AgentAvatar.tsx create mode 100644 ksadk/studio/react-ui/src/components/ChatRunPanel.tsx create mode 100644 ksadk/studio/react-ui/src/components/ChatWorkspace.tsx create mode 100644 ksadk/studio/react-ui/src/components/ComposerActionMenu.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/ComposerActionMenu.tsx create mode 100644 ksadk/studio/react-ui/src/components/ConfirmDialog.tsx create mode 100644 ksadk/studio/react-ui/src/components/Drawer.tsx create mode 100644 ksadk/studio/react-ui/src/components/NavigationRail.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/NavigationRail.tsx create mode 100644 ksadk/studio/react-ui/src/components/PythonToolExample.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/PythonToolExample.tsx create mode 100644 ksadk/studio/react-ui/src/components/RuntimeModeBar.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/RuntimeModeBar.tsx create mode 100644 ksadk/studio/react-ui/src/components/SettingsOverlay.tsx create mode 100644 ksadk/studio/react-ui/src/components/SkillFileBrowser.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/SkillFileBrowser.tsx create mode 100644 ksadk/studio/react-ui/src/components/Toast.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/CodeViewer.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/CodeViewer.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/FileDropzone.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/FileDropzone.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/FormField.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/FormField.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/GeneratedIdField.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/GeneratedIdField.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/MarkdownPreview.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/PrismRenderer.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/StudioDataTable.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/StudioDataTable.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/StudioDialog.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/StudioDialog.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/StudioMultiSelect.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/StudioMultiSelect.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/StudioScrollArea.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/StudioSelect.test.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/StudioSelect.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/badge.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/button.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/card.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/input.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/separator.tsx create mode 100644 ksadk/studio/react-ui/src/components/ui/tabs.tsx create mode 100644 ksadk/studio/react-ui/src/composerActions.test.ts create mode 100644 ksadk/studio/react-ui/src/composerActions.ts create mode 100644 ksadk/studio/react-ui/src/createPage.component-contract.test.mjs create mode 100644 ksadk/studio/react-ui/src/index.css create mode 100644 ksadk/studio/react-ui/src/lib/formErrors.ts create mode 100644 ksadk/studio/react-ui/src/lib/generatedId.ts create mode 100644 ksadk/studio/react-ui/src/lib/utils.test.ts create mode 100644 ksadk/studio/react-ui/src/lib/utils.ts create mode 100644 ksadk/studio/react-ui/src/main.tsx create mode 100644 ksadk/studio/react-ui/src/pages/AgentDetailPage.tsx create mode 100644 ksadk/studio/react-ui/src/pages/AgentEditor.test.tsx create mode 100644 ksadk/studio/react-ui/src/pages/AgentEditor.tsx create mode 100644 ksadk/studio/react-ui/src/pages/AgentsPage.test.tsx create mode 100644 ksadk/studio/react-ui/src/pages/AgentsPage.tsx create mode 100644 ksadk/studio/react-ui/src/pages/BuildsPage.tsx create mode 100644 ksadk/studio/react-ui/src/pages/CreatePage.test.tsx create mode 100644 ksadk/studio/react-ui/src/pages/CreatePage.tsx create mode 100644 ksadk/studio/react-ui/src/pages/DeploymentsPage.tsx create mode 100644 ksadk/studio/react-ui/src/pages/ObservabilityPage.tsx create mode 100644 ksadk/studio/react-ui/src/pages/OrchestrationPage.test.tsx create mode 100644 ksadk/studio/react-ui/src/pages/OrchestrationPage.tsx create mode 100644 ksadk/studio/react-ui/src/pages/ResourcesPage.tsx create mode 100644 ksadk/studio/react-ui/src/pages/RuntimeResourcesPage.tsx create mode 100644 ksadk/studio/react-ui/src/responsive.css create mode 100644 ksadk/studio/react-ui/src/responsiveViewport.test.mjs create mode 100644 ksadk/studio/react-ui/src/responsiveViewport.ts create mode 100644 ksadk/studio/react-ui/src/schemas/agentForms.test.ts create mode 100644 ksadk/studio/react-ui/src/schemas/agentForms.ts create mode 100644 ksadk/studio/react-ui/src/schemas/resourceForms.test.ts create mode 100644 ksadk/studio/react-ui/src/schemas/resourceForms.ts create mode 100644 ksadk/studio/react-ui/src/skillBatchImport.test.mjs create mode 100644 ksadk/studio/react-ui/src/skillBatchImport.ts create mode 100644 ksadk/studio/react-ui/src/studio.css create mode 100644 ksadk/studio/react-ui/src/studioTheme.test.mjs create mode 100644 ksadk/studio/react-ui/src/studioTheme.ts create mode 100644 ksadk/studio/react-ui/src/test/setup.ts create mode 100644 ksadk/studio/react-ui/src/theme.css create mode 100644 ksadk/studio/react-ui/src/useStudioTheme.ts create mode 100644 ksadk/studio/react-ui/src/useStudioViewportMode.ts create mode 100644 ksadk/studio/react-ui/tsconfig.json create mode 100644 ksadk/studio/react-ui/vite.config.ts create mode 100644 ksadk/studio/react-ui/vitest.config.ts create mode 100644 ksadk/studio/repository.py create mode 100644 ksadk/studio/resource_catalog.py create mode 100644 ksadk/studio/run_service.py create mode 100644 ksadk/studio/runtime_catalog.py create mode 100644 ksadk/studio/runtime_source.py create mode 100644 ksadk/studio/service.py create mode 100644 ksadk/studio/shared_web.py create mode 100644 ksadk/studio/skill_discovery.py create mode 100644 ksadk/studio/templates.py create mode 100644 ksadk/studio/validator.py create mode 100644 ksadk/studio/workspace.py create mode 100644 ksadk/toolsets/a2a.py delete mode 100644 ksadk/tracing/exporters/langfuse_exporter.py create mode 100644 tests/runners/test_adapter_contract.py delete mode 100644 tests/runners/test_adk_runner.py delete mode 100644 tests/runners/test_codex_runner.py delete mode 100644 tests/test_a2a_agent_card.py delete mode 100644 tests/test_a2a_text_replacement.py delete mode 100644 tests/test_security_boundaries.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a55a6a92..cf7c731a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,7 +8,7 @@ - [ ] `make public-audit` - [ ] `make docs-site-build` - [ ] `make open-source-audit-dist` if package artifacts changed. -- [ ] `uv build` +- [ ] `make build-wheel` - [ ] `uv run --extra dev python -m twine check dist/*` ## Public Surface diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fc7893e..80896051 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: test: runs-on: ubuntu-latest env: - KSADK_WEB_VERSION: "0.3.0" + KSADK_WEB_VERSION: "0.3.1" steps: - uses: actions/checkout@v4 @@ -39,8 +39,8 @@ jobs: - name: Install dependencies run: uv sync --extra all - - name: Sync KsADK Web static assets - run: make public-sync-ksadk-web-static + - name: Build frontend static assets + run: make build-frontend - name: Build package artifacts run: uv build @@ -60,6 +60,36 @@ jobs: - name: Audit wheel and sdist file lists run: make open-source-audit-dist + test-studio-react: + name: React Studio responsive browser gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ksadk/studio/react-ui/package-lock.json + + - name: Install Python dependencies + run: uv sync --extra dev + + - name: Install Chromium + run: uv run playwright install --with-deps chromium + + - name: Run React Studio browser gate + run: make studio-react-test + # goal-00: google-adk 多版本 matrix。依赖窗口 >=1.34.0,<3.0, # 对最低锚点 1.34.x 与最新 2.x(2.5.x)各跑一遍全量测试,防止任何一端回归。 # 版本差异统一由 ksadk/compat/adk_compat.py 收口(见 docs/adk-multi-version-compat.md)。 @@ -67,7 +97,7 @@ jobs: name: full pytest (google-adk ${{ matrix.google-adk }}) runs-on: ubuntu-latest env: - KSADK_WEB_VERSION: "0.3.0" + KSADK_WEB_VERSION: "0.3.1" strategy: fail-fast: false matrix: @@ -83,6 +113,13 @@ jobs: with: python-version: "3.11" + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ksadk/studio/react-ui/package-lock.json + - name: Install dependencies run: uv sync --extra all @@ -92,10 +129,10 @@ jobs: - name: Verify google-adk version run: uv run --no-sync python -c "from importlib.metadata import version; print('google-adk', version('google-adk'))" - # Static web assets are deliberately not tracked in the Python source - # tree; fetch and verify the released npm payload before wheel build. - - name: Sync KsADK Web static assets - run: make public-sync-ksadk-web-static + # Static frontend assets are deliberately not tracked in the Python + # source tree; generate both payloads before wheel build. + - name: Build frontend static assets + run: make build-frontend # Packaging tests inspect the wheel itself; build it in each isolated ADK # environment rather than depending on an artifact from another job. diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 3eebf2b5..86fb7962 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -9,7 +9,7 @@ on: ksadk_web_version: description: KsADK Web npm version to bundle required: false - default: "0.3.0" + default: "0.3.1" approved_source_commit: description: Reviewed source commit SHA recorded in docs/maintainer-approval-record.md required: false @@ -37,7 +37,7 @@ jobs: environment: name: pypi env: - KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.3.0' }} + KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.3.1' }} KSADK_APPROVED_SOURCE_COMMIT: ${{ github.event.inputs.approved_source_commit || vars.KSADK_APPROVED_SOURCE_COMMIT }} PUBLISH_TARGET: ${{ github.event.inputs.publish_target || 'full' }} permissions: diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 92252ab9..47fd0506 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -35,10 +35,10 @@ jobs: - name: Install dependencies run: uv sync --extra dev - - name: Sync pinned KsADK Web static assets + - name: Build pinned frontend static assets env: - KSADK_WEB_VERSION: "0.3.0" - run: make public-sync-ksadk-web-static + KSADK_WEB_VERSION: "0.3.1" + run: make build-frontend - name: Build artifacts run: uv build diff --git a/.gitignore b/.gitignore index 328808ca..e0debd32 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ dist/ downloads/ eggs/ lib/ +!ksadk/studio/react-ui/src/lib/ lib64/ parts/ sdist/ @@ -83,8 +84,14 @@ webui # Generated from @kingsoftcloud/ksadk-web during release/build. ksadk/server/static/** +# Generated from the tracked React Studio source during release/build. +ksadk/studio/static/** +ksadk/studio/web/node_modules/ +ksadk/studio/web/dist/ site/ # Legacy local UI source is owned by kingsoftcloud/ksadk-web. ksadk/server/web-ui/ e2e-codex-agent/ +ksadk/studio/react-ui/node_modules/ +.agentkit/secrets.env diff --git a/CHANGELOG.md b/CHANGELOG.md index 479965c3..4abdd69e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,80 @@ ## [Unreleased] +## [0.8.1] - 2026-08-10 + +### 亮点 + +- **首次交付 AgentKit Studio**:`0.8.0` 尚未包含 Studio;`0.8.1` 新增 `agentengine studio` 本地 Agent 构建与运行控制台。首版直接采用单一 React shell,统一承载 Agent、会话、构建、部署、工程资源、可观测性、运行资源和任务编排页面;React 是首次交付时的前端选型,不是从既有 vanilla Studio 迁移而来。 +- **Agent 全生命周期在一个本地工作区闭环**:支持快速创建、对话构建、YAML/Agent ZIP 导入、ADK/LangGraph 项目识别,以及 Agent 配置、能力绑定、校验、构建、运行、评估、部署、回滚和操作进度查询。根路径直接进入 Agent 工作区,不引入需要单独维护的 `/chat` 前端入口。 +- **对话运行升级为可暂停、可继续的人机交互状态机**:在 `0.8.0` RuntimeEvent v1、AG-UI/A2UI 和 RuntimeAdapter 基础上,打通 Responses/SSE 增量输出、持久化事件回放、切换会话与刷新恢复、暂停/继续/取消、审批提交和结构化用户输入。ADK、LangGraph 与 Codex 共用运行状态和交互投影,不要求 Studio 为每个框架维护一套私有渲染协议。 +- **本地可观测性成为 Studio 的一等能力**:新增运行概览、趋势卡片、可分页筛选的 Trace 列表、Span 瀑布时间线、详情面板、Attributes/Events/Resource 浏览,以及格式化并高亮的 Raw OTLP 预览;会话运行详情可直接关联完整 Trace。 +- **标准 OTLP 成为唯一远端 trace 通路**:每个 Python Agent 进程使用一个 `TracerProvider`,由两个 OTLP/HTTP `BatchSpanProcessor` 将同一批 spans 写入 Langfuse 主路(标准 `OTEL_EXPORTER_OTLP_*`)和 CloudMonitor 次路(平台 `CLOUD_MONITOR_OTLP_*`),两端保持相同的 `trace_id` / `span_id`。不再部署 CallbackHandler、Langfuse SDK exporter、Collector、sidecar、额外容器或额外 Pod。 +- **托管可观测性默认开启**:`agentengine deploy`、`agentengine launch` 和 `agentengine hermes deploy` 默认请求平台可观测性;CLI 与控制台共用控制面开关,由平台注入主路和次路配置。只有显式选择 `--no-observability` 或在控制台关闭时才禁用。 + +### 新增 + +- 新增 Studio 本地控制面与版本化 API,覆盖 workspace、Agent、模板、资源目录、模型配置、凭证引用、构建、运行、会话、Trace、评估、部署和长操作;本地写请求使用随机 session token 与 CSRF 校验,凭证可从当前进程、全局配置或项目配置解析。 +- 新增模型、Python Tool、MCP 与 Skill 资源管理。Python Tool 支持源码上传、只读检查、可复制示例和 schema 校验;Skill 支持候选扫描、逐项复核导入、冲突确认、文件树浏览以及 Markdown/代码高亮预览。 +- 新增 Agent 外观配置和图标资源上传;Agent 本地标识默认使用带随机后缀的稳定 ID,表单统一标明必填、选填和自动生成字段,并提供文件拖放、多选、选择器、数据表、对话框与代码预览等共享 React 组件。 +- 新增 Studio 会话工作区:单行紧凑会话列表、流式正文与思考/工具 activity、上下文窗口用量、附件入口、三档审批策略、`/goal` / `/plan` 命令和运行模式条。审批等级从下一轮实时生效,生成期间发送按钮切换为暂停/停止控制。 +- 新增 A2UI surface 增量归并、持久化回放和交互回传,支持确认、单选、多选与自定义输入;无 A2UI surface 时,工具、审批和 RuntimeEvent 仍以克制的通用 activity 卡片展示。 +- 新增运行资源与任务编排页面,将 Runtime、模型、Tool/MCP/Skill 绑定、路由约束和最近调度投影为可检查的 DAG;节点根据画布宽度自适应布局。 +- 新增 AgentKit manifest、Codex Agent 构建与运行服务、资源/能力目录、操作事件流、Agent 头像资产以及本地 OTLP Trace 存储与查询能力。 +- `agentengine hermes deploy` 新增可重复的 `--env KEY=VALUE` 与 `--env-file`(dotenv 或 JSON 对象),并可自动发现当前目录 `.env`;优先级为 `--env` > `--env-file` > 当前进程环境 > 自动 `.env`。显式环境参数会进入新建或更新 payload,未显式指定时更新已有 Hermes 不覆盖服务端环境配置。OpenClaw deploy 同步复用该解析规则并新增 `--env-file` 与自动 `.env` 加载。 +- 新增 React Studio 专项 CI 门禁,覆盖协议归并、组件行为、TypeScript、生产构建、样式契约、桌面浏览器烟测和常见分辨率响应式烟测,并校验随 Python 包分发的静态产物与 React 源码一致。 + +### 变更 + +- Runtime 启动路径收敛到显式的 factory、adapter 和 conversation execution 边界;CLI、Web、A2A、AG-UI 与 Studio 共用同一运行时装配逻辑,减少模块级全局 runner 和重复 SSE 实现。 +- Codex 从旧 runner 路径收敛到 `CodexRuntimeAdapter` 与官方 app-server transport,补齐模型代理、HOME 隔离、审批/沙箱策略、全局凭证继承、结构化交互事件和中断后的流式 transport 回收。 +- Responses 输入、会话历史、运行事件、tool/approval/A2UI activity 和 usage 在服务端统一投影;`/v1/responses` 保持主要会话接口,`/v1/chat/completions` 继续作为 OpenAI-compatible 协议入口,而不是 Studio 页面路由。 +- Studio 前端源码位于 `ksadk/studio/react-ui`,Python 包中的 `ksadk/studio/static` 只保存其生产构建产物;首次交付不引入另一套 vanilla Studio 前端。 +- Studio 桌面布局统一适配 MacBook 14 英寸及常见 2K/4K 视口;导航、DAG 画布、会话列表、Composer、深浅色主题和 Trace 详情共享设计令牌与响应式规则。手机竖屏不属于 `0.8.1` 支持范围。 +- 删除 `langfuse_exporter.py`、`runners/utils/langfuse.py`、`LANGFUSE_USE_CALLBACK` 和 Langfuse SDK callback 路径;`tracing` extra 不再安装 `langfuse`,仅保留 OpenInference 自动插桩。 +- 调整 Python 包依赖边界:`langchain-openai` 仅随 `langchain`、`langgraph` 和 `deepagents` framework extras 安装,不再由基础 `ksadk` wheel 强制安装。这样 Hermes 等不使用 LangChain OpenAI adapter 的运行时可保留其已验证的 OpenAI SDK 版本;选择这些框架的生成项目仍会声明并安装同一受支持版本范围。 +- `all` extra 纳入 Codex 支持;Studio 运行与测试依赖补齐 MCP、Pillow 和 Playwright,wheel/sdist 继续只打包 React 生产静态资源而不包含 Node.js 源码依赖。 +- `agentengine hermes exec` 增加显式 `--agent` 目标并改为 argv 原样透传,由远端 Pod 执行最终命令策略;普通 argv 不再被猜测为 Agent 名称,`--session` 业务会话 ID 会写入 terminal start frame。 +- 用户显式调用 `save_memory` 时向 SDK 记忆后端发送 `flush=True`,保证本次数据完成抽取后再返回;自动轮次保存继续使用后端默认批处理语义。 +- `agentengine studio` 默认继续仅监听 loopback,但端口调整为 `8080`;可用 `--port` 覆盖。Studio 前端源码开发代理也使用同一默认端口。 + +### 修复与性能 + +- 修复 LangGraph 回调将 ToolGateway 结果序列化为 JSON 文本时,工具审批未被识别为可恢复交互的问题;Responses 客户端现在会收到标准审批项,批准后可继续原工具调用并执行真实副作用。 +- 修复 LangGraph 中 ToolGateway 审批完成后向已结束图发送原生 resume、导致副作用虽已执行却没有后续回复的问题;现在会基于已持久化的真实工具结果继续生成最终回答,同时保留原生 `interrupt()` 的 resume 语义。 +- 修复 Studio 快速创建向导与模板编排 API 的请求契约,并将 ADK/LangGraph 的源码路径和入口变量完全交由服务端生成;“创建后立即构建并打开会话”现在会实际提交 Build、等待成功后再进入会话。Codex、ADK、LangGraph 三种 Runtime 均按同一流程创建和构建。 +- 修复 Codex RuntimeAdapter 事件信封丢失调用方 `agent_id` / `user_id` / `invocation_id` 的问题,避免通过 `/v1/responses` 运行时因作用域校验不一致返回 500。 +- 修复通用 Agent 创建与更新部署 payload 未标记敏感环境变量的问题;模型 API Key、Token、Secret 等现在按统一规则写入 `IsSensitive`,避免控制面将其按普通变量处理。 +- 修复 Codex ManagedRuntime 的本地声明构建与请求组装:部署/`--dry-run` 会加载项目 YAML、计算 manifest SHA-256 并传给控制面,而不把部署参数误当作 Agent manifest。 +- 修复 Codex 中断后 SDK transport 残留后台等待任务,避免事件循环退出阶段挂起。 +- CloudMonitor traces 专用 endpoint、protocol 和 headers 分别优先于通用配置;`CLOUD_MONITOR_OTLP_TRACES_HEADERS` 与 `CLOUD_MONITOR_OTLP_HEADERS` 都支持 RFC 3986 percent-encoded values。 +- `CLOUD_MONITOR_APP_KEY` 降级为一个版本的过渡 fallback:仅当 traces 和通用 headers 环境变量都整体缺失时才翻译为 `Ksc-Appkey`。任一 headers 变量已提供但无有效 `Ksc-Appkey` 时 fail closed,不混入旧 AppKey。 +- AgentEngine 托管 runtime 在模块级 `ksadk.server.app:app` 与 `BaseRunner.run_server()` 两个真实入口都按 `KSADK_A2A_RUNTIME_ID` 挂载 discovery-only `/.well-known/agent-card.json`;卡片明确声明 `streaming=false`,不开放 JSON-RPC、REST Task 或其他 A2A 数据面路由。 +- `A2ASpaceClient.from_env()` 优先读取 `KSADK_A2A_SPACE_ID`,并保留对单元素 `KSADK_A2A_SPACE_IDS` JSON 数组的兼容读取。 +- A2A 核心依赖改为 `a2a-sdk[fastapi]`;PostgreSQL TaskStore 支持移到可选 `ksadk[a2a-postgres]`,discovery-only runtime 不再因 A2A 被强制安装 PostgreSQL adapter。会话系统既有 `asyncpg` 依赖保持不变。 + +### 兼容性、迁移与评审边界 + +- `0.8.1` 是 AgentKit Studio 的首次交付,不存在从 `0.8.0` Studio 或 vanilla Studio 迁移的问题。Studio 只有一个 React 前端入口;自研 UI 仍可直接消费 Responses/SSE、RuntimeEvent、AG-UI/A2UI 和运行控制 API,不要求使用 React。 +- RuntimeEvent schema 继续保持 v1 additive 兼容;新增交互和运行控制通过追加事件类型与控制 API 表达,不修改既有事件字段语义。 +- 旧 `LANGFUSE_*` 凭证不再创建 SDK callback/exporter。迁移时把 Langfuse OTLP endpoint 与 Authorization header 配置到标准 `OTEL_EXPORTER_OTLP_*`。 +- 新部署使用 `CLOUD_MONITOR_OTLP_TRACES_HEADERS` 或 `CLOUD_MONITOR_OTLP_HEADERS` 提供 `Ksc-Appkey`;`CLOUD_MONITOR_APP_KEY` 仅用于旧控制面的短期兼容。 +- A2A 环境变量明确区分部署期 `KSADK_A2A_RUNTIME_ID` 与注册后 `KSADK_A2A_AGENT_ID`;v1 discovery card 只依赖前者。 +- Codex `ksadk init` 的本地 `web`/Responses 运行与 ManagedRuntime 请求组装已验证;真实云端部署仍依赖服务端发布 Runtime catalog、对应 Linux runtime image 和内联 manifest 的公开控制面契约。该服务端能力未在本次 SDK 发布中宣称可用,SDK 不会把 Codex 静默降级为 Code 部署。 + +### 文档 + +- 同步中英文 README、Studio CLI、可观测性、环境变量、知识库与记忆库指南;新增可从左侧导航进入的 AgentKit Local Studio 指南,补充首次 Studio 交付、React 源码/构建产物边界、运行控制与交互协议、默认 OTLP 双写、显式关闭、header 优先级、旧 AppKey 边界以及 Hermes session 语义。 + +### 发布记录 + +- Python:`ksadk==0.8.1` +- 内置 Web UI:`@kingsoftcloud/ksadk-web@0.3.1`(source `b4e9f938828ef669347dadb7f0eb3f0a01747a6a`) +- AgentKit Studio:首次交付,React 单一前端随 Python wheel 分发生产构建产物 +- 当前状态:内部发版候选;相对 `0.8.0` 的 Studio、Runtime、Codex、可观测性、CLI、依赖与文档变更均记录于本节 +- 本地发版验证:Studio 快速创建、构建和会话入口已覆盖 Codex、ADK、LangGraph;`ksadk init -f ` 生成的三套模板均已通过 `ksadk web`、`/v1/responses` 本地对话和 `ksadk deploy --dry-run` 请求规划验证。ADK、LangGraph 另已完成隔离环境中的真实部署和调用;Codex 真实云端部署待上述服务端 Runtime 上线后复验。 +- PyPI、tag、GitHub Release 与公开文档站仍须通过受信 workflow 和维护者批准后发布 + ## [0.8.0] - 2026-07-29 ### 亮点 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33c43271..6710d0f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ uv run --extra dev pytest -q make open-source-audit make docs-site-build make public-audit -uv build +make build-wheel uv run --extra dev python -m twine check dist/* ``` @@ -31,6 +31,10 @@ generated snapshots. `open-source-audit` checks the current public repository candidate for files that should not enter the open-source surface. +`make build-wheel` is the local package-build entrypoint. It synchronizes the +pinned `ksadk-web` static payload and generates the ignored React Studio +payload before running `uv build`. + Do not push, publish, or create a release before maintainer review. ## Pull Requests diff --git a/MANIFEST.in b/MANIFEST.in index 88243b68..37067272 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ prune ksadk/server/web-ui recursive-exclude ksadk/server/web-ui * +prune ksadk/studio/react-ui +recursive-exclude ksadk/studio/react-ui * recursive-include ksadk_runtime_common/schemas *.json diff --git a/Makefile b/Makefile index b21e3372..563a5b9e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # AgentEngine Makefile # 用于同步 KsADK Web static 和管理项目 -.PHONY: help install clean clean-cache clean-dist clean-static clean-offline dev test publish publish-test public-status public-init-worktree public-worktree-status public-sync-check public-secret-audit public-audit public-version-gate docs-site-build docs-site-dev public-test public-build-check public-build-alias-check public-preflight public-publish-check public-release-approval-check public-publish-gate public-release-tag public-review public-sync-ksadk-web-static open-source-audit-dist open-source-audit-alias-dist openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size sync-ksadk-web-static verify-ksadk-web-static verify-ksadk-web-wheel-static sync-hosted-ui build-frontend build-webui sync-static webui build-wheel build-all clean-frontend +.PHONY: help install clean clean-cache clean-dist clean-static clean-offline dev test publish publish-test public-status public-init-worktree public-worktree-status public-sync-check public-secret-audit public-audit public-version-gate docs-site-build docs-site-dev public-test public-build-check public-build-alias-check public-preflight public-publish-check public-release-approval-check public-publish-gate public-release-tag public-review public-sync-ksadk-web-static open-source-audit-dist open-source-audit-alias-dist openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size sync-ksadk-web-static verify-ksadk-web-static verify-ksadk-web-wheel-static build-studio-static sync-hosted-ui build-frontend build-webui sync-static webui build-wheel build-all clean-frontend # 默认目标 help: @@ -14,9 +14,10 @@ help: @echo " make test 运行测试" @echo "" @echo " \033[1;32mWeb UI 构建:\033[0m" - @echo " make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0" + @echo " make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.1" @echo " 从 @kingsoftcloud/ksadk-web npm 包同步 static" - @echo " make build-frontend 同步 ksadk-web static" + @echo " make build-frontend 准备 ksadk-web 与 React Studio static" + @echo " make build-studio-static 编译 React Studio static" @echo "" @echo " \033[1;32m版本管理:\033[0m" @echo " make version 显示当前版本" @@ -100,7 +101,20 @@ dev-backend: test: @echo "🧪 运行 Python 测试..." - pytest tests/ -v + uv run --extra all pytest tests/ -v + +studio-react-install-browser: + uv run playwright install chromium + +studio-react-test: + npm --prefix ksadk/studio/react-ui ci + npm --prefix ksadk/studio/react-ui test + npm --prefix ksadk/studio/react-ui run test:ui + cd ksadk/studio/react-ui && npx tsc --noEmit + npm --prefix ksadk/studio/react-ui run build + uv run pytest tests/studio/test_style_system.py -q + PYTHONPATH=. uv run python tests/studio/e2e/studio_browser_smoke.py + PYTHONPATH=. uv run python tests/studio/e2e/studio_responsive_smoke.py # ============================================================ # 构建和发布 @@ -172,7 +186,7 @@ subprocess.run(['sed', '-i', '', f's/^version = \".*\"/version = \"{new_v}\"/', check-build-deps: @python -c "import build" 2>/dev/null || (echo "📦 安装构建依赖..." && pip install build twine) -build: check-build-deps sync-ksadk-web-static +build: check-build-deps sync-ksadk-web-static build-studio-static @echo "📦 构建 Python 包 v$(VERSION)..." python -m build @# 删除 tar.gz 和临时目录,只保留 whl @@ -182,8 +196,8 @@ build: check-build-deps sync-ksadk-web-static @ls -la dist/ # 仅构建 Python 包(跳过 npm 同步,使用现有静态文件) -build-only: check-build-deps - @echo "📦 构建 Python 包 v$(VERSION)(使用现有静态文件)..." +build-only: check-build-deps build-studio-static + @echo "📦 构建 Python 包 v$(VERSION)(使用已同步 Web static)..." @if [ ! -f "ksadk/server/static/index.html" ]; then \ echo "❌ 错误: ksadk/server/static/ 目录为空,请先运行 make sync-ksadk-web-static"; \ exit 1; \ @@ -267,7 +281,7 @@ PUBLIC_DOCS_URL ?= https://kingsoftcloud.github.io/ksadk-python/ PUBLIC_PYPI_PROJECT ?= ksadk PUBLIC_ALIAS_PYPI_PROJECT ?= agentengine-sdk-python PUBLIC_RELEASE_TAG ?= v$(V) -PUBLIC_TEST_TARGETS ?= tests/test_public_release_positioning.py tests/test_config_env_registry.py tests/test_managed_runtime_builder.py tests/test_managed_runtime_resolution.py tests/cli/test_cmd_create_codex.py tests/runners/test_codex_runner.py +PUBLIC_TEST_TARGETS ?= tests/test_public_release_positioning.py tests/test_config_env_registry.py tests/test_managed_runtime_builder.py tests/test_managed_runtime_resolution.py tests/cli/test_cmd_create_codex.py tests/runners/test_adapter_contract.py public-status: @echo "==> internal worktree" @@ -374,7 +388,7 @@ public-test: public-sync-ksadk-web-static: sync-ksadk-web-static -public-build-check: clean-dist sync-ksadk-web-static +public-build-check: clean-dist sync-ksadk-web-static build-studio-static @echo "==> build and twine check" @uv build @$(MAKE) verify-ksadk-web-wheel-static @@ -563,10 +577,12 @@ openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size: # ============================================================ STATIC_DIR := ksadk/server/static -# The wheel must embed a published, reproducible Web bundle. 0.8.0 is coupled -# to the 0.3.0 Web release; the release job must fail rather than silently +STUDIO_REACT_DIR := ksadk/studio/react-ui +STUDIO_STATIC_DIR := ksadk/studio/static +# The wheel must embed a published, reproducible Web bundle. 0.8.x is coupled +# to the 0.3.1 Web release; the release job must fail rather than silently # substituting an older npm package when that release is not visible yet. -KSADK_WEB_VERSION ?= 0.3.0 +KSADK_WEB_VERSION ?= 0.3.1 KSADK_WEB_PACKAGE ?= @kingsoftcloud/ksadk-web KSADK_WEB_TARBALL_NAME := kingsoftcloud-ksadk-web-$(patsubst v%,%,$(KSADK_WEB_VERSION)).tgz KSADK_WEB_RELEASE_URL ?= @@ -619,11 +635,17 @@ verify-ksadk-web-wheel-static: --wheel "$$(ls dist/ksadk-*.whl)" \ --expected-version "$(patsubst v%,%,$(KSADK_WEB_VERSION))" +build-studio-static: + @echo "Build React Studio static assets from $(STUDIO_REACT_DIR)" + npm --prefix "$(STUDIO_REACT_DIR)" ci + npm --prefix "$(STUDIO_REACT_DIR)" run build + @test -f "$(STUDIO_STATIC_DIR)/index.html" || (echo "ERROR: Studio static index.html missing after build" && exit 1) + sync-hosted-ui: sync-ksadk-web-static @echo "sync-hosted-ui is deprecated; static assets now come from $(KSADK_WEB_PACKAGE)." -build-frontend: sync-ksadk-web-static - @echo "Frontend static assets synced from $(KSADK_WEB_VERSION)" +build-frontend: sync-ksadk-web-static build-studio-static + @echo "Frontend static assets prepared for packaging" build-wheel: build-frontend uv build @@ -632,7 +654,7 @@ build-all: build-wheel @echo "Build complete. Wheel is in dist/" clean-frontend: - rm -rf $(STATIC_DIR) + rm -rf $(STATIC_DIR) $(STUDIO_STATIC_DIR) # ============================================================ # 清理 @@ -654,7 +676,7 @@ clean-cache: clean-static: @echo "🧹 清理静态文件..." - rm -rf $(STATIC_DIR)/* + rm -rf $(STATIC_DIR)/* $(STUDIO_STATIC_DIR) @echo "✅ 清理完成" clean-offline: diff --git a/README.en.md b/README.en.md index e9949b17..ea1ceab6 100644 --- a/README.en.md +++ b/README.en.md @@ -37,6 +37,15 @@ Start the local debugging Web UI: agentengine web . --no-open ``` +## 0.8.1 Observability Contract + +- Remote traces use standard OTLP/HTTP only: Langfuse consumes `OTEL_EXPORTER_OTLP_*`, while CloudMonitor consumes `CLOUD_MONITOR_OTLP_*`. Both backends receive the same span with identical `trace_id` and `span_id` values. +- Managed Agents created from either the CLI or console request observability by default and receive both routes from the platform. Use `--no-observability`, or turn observability off in the console, to disable it explicitly. +- `LANGFUSE_USE_CALLBACK` and the Langfuse SDK CallbackHandler/exporter have been removed. `CLOUD_MONITOR_APP_KEY` remains only as a one-version transition fallback; new configurations provide `Ksc-Appkey` through OTLP headers. +- Exporters run directly inside the Agent process. No OpenTelemetry Collector, sidecar, extra container, or extra Pod is started. + +See the [observability guide](https://kingsoftcloud.github.io/ksadk-python/en/docs/framework/guides/observability-tracing/) and [environment variable reference](https://kingsoftcloud.github.io/ksadk-python/en/docs/references/environment-variables/) for migration details and examples. +

Real KsADK Web UI debugging screenshot

Real local Web UI demo

diff --git a/README.md b/README.md index c749e226..eee2f3a5 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,15 @@ agentengine run -i agentengine web . --no-open ``` +## 0.8.1 可观测性契约 + +- 远端 trace 统一使用标准 OTLP/HTTP:Langfuse 读取 `OTEL_EXPORTER_OTLP_*`,CloudMonitor 读取 `CLOUD_MONITOR_OTLP_*`;同一 span 在两端保持相同的 `trace_id` / `span_id`。 +- 托管 Agent 通过 CLI 或控制台创建时默认开启可观测性并由平台注入双路配置;只有显式传入 `--no-observability` 或在控制台关闭才禁用。 +- `LANGFUSE_USE_CALLBACK`、Langfuse SDK CallbackHandler/exporter 已移除。`CLOUD_MONITOR_APP_KEY` 只保留一个版本的过渡 fallback,新配置应通过 OTLP headers 提供 `Ksc-Appkey`。 +- exporter 直接运行在 Agent 进程内,不会额外启动 OpenTelemetry Collector、sidecar、容器或 Pod。 + +迁移与环境变量示例见[可观测指南](https://kingsoftcloud.github.io/ksadk-python/cn/docs/framework/guides/observability-tracing/)和[环境变量参考](https://kingsoftcloud.github.io/ksadk-python/cn/docs/references/environment-variables/)。 +

KsADK 真实 Web UI 调试截图

KsADK 真实本地 Web UI 演示

diff --git a/README.zh-CN.md b/README.zh-CN.md index 4d3355ec..abc7fb73 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,6 +37,15 @@ agentengine run -i agentengine web . --no-open ``` +## 0.8.1 可观测性契约 + +- 远端 trace 统一使用标准 OTLP/HTTP:Langfuse 读取 `OTEL_EXPORTER_OTLP_*`,CloudMonitor 读取 `CLOUD_MONITOR_OTLP_*`;同一 span 在两端保持相同的 `trace_id` / `span_id`。 +- 托管 Agent 通过 CLI 或控制台创建时默认开启可观测性并由平台注入双路配置;只有显式传入 `--no-observability` 或在控制台关闭才禁用。 +- `LANGFUSE_USE_CALLBACK`、Langfuse SDK CallbackHandler/exporter 已移除。`CLOUD_MONITOR_APP_KEY` 只保留一个版本的过渡 fallback,新配置应通过 OTLP headers 提供 `Ksc-Appkey`。 +- exporter 直接运行在 Agent 进程内,不会额外启动 OpenTelemetry Collector、sidecar、容器或 Pod。 + +迁移与环境变量示例见[可观测指南](https://kingsoftcloud.github.io/ksadk-python/cn/docs/framework/guides/observability-tracing/)和[环境变量参考](https://kingsoftcloud.github.io/ksadk-python/cn/docs/references/environment-variables/)。 +

KsADK 真实 Web UI 调试截图

KsADK 真实本地 Web UI 演示

diff --git a/docs-site/content/docs/cli/index.en.mdx b/docs-site/content/docs/cli/index.en.mdx index 2f925b2e..eb1dd16b 100644 --- a/docs-site/content/docs/cli/index.en.mdx +++ b/docs-site/content/docs/cli/index.en.mdx @@ -25,6 +25,7 @@ agentengine --help agentengine init --help agentengine run --help agentengine web --help +agentengine studio --help ``` ## Command Groups @@ -35,6 +36,7 @@ agentengine web --help | `config` | manage model and project settings | yes | | `run` | run terminal loop or local API server | yes | | `web` | start local browser UI | yes | +| `studio` | start the local Agent authoring and debugging workspace | yes | | `a2a` | expose A2A surfaces where configured | yes | | `replay` | read and project RuntimeEvent history | yes | | `mcp` | build or manage MCP resources | depends on target | @@ -134,6 +136,30 @@ Codex ManagedRuntime uses the same `agentengine web . --no-open` command. It validates the native Codex binary for the current operating system and reports when an offline Runtime version is not explicitly locked. +### `agentengine studio` + +Start the loopback-only local Agent authoring workspace: + +```bash title="bash" +agentengine studio . +agentengine studio . --port 8081 --no-open +agentengine studio . --env-file ./model.env --codex-proxy auto +``` + +Studio uses one React shell for Agents, conversations, builds, resources, +traces, and orchestration. The root URL opens the workspace directly; there is +no separately maintained `/chat` frontend. Model settings can come from the +current process, global configuration, or an explicit `--env-file`. Browser +writes are protected by the local session and CSRF tokens. + +`studio/static` contains the packaged React production build but is not tracked +by Git. Frontend sources and tests live in `ksadk/studio/react-ui`; run +`make studio-react-test` after changing them, then use `make build-wheel` or +`make public-build-check` to regenerate and package the assets. + +For the full workflow from starting a workspace through creating, building, and +testing an Agent, see [AgentKit Local Studio (New in 0.8.1)](/en/docs/framework/guides/agentkit-local-studio). + ### `ksadk replay` / `agentengine replay` Project a session's persisted RuntimeEvent log into a readable transcript or JSON. Use it to investigate disconnected streams, restored approval state, A2UI activities, and event ordering across runners. It **never** calls a model, tool, or approval action again. @@ -221,6 +247,8 @@ agentengine launch . --env KEY=VALUE --env-file ./runtime.env | `--env KEY=VALUE` | extra runtime environment variable, repeatable; explicit env goes into the deploy payload `env_vars`. | | `--env-file PATH` | extra runtime environment variable file, accepts `.env` or a JSON object. | +Sensitive credentials, including secrets passed with `--env`, are redacted from `--dry-run` output. + The `--env-file` you pass is for explicit runtime env injection. Real `.env` / `.env.local` files are excluded from Code, Container, and MCP build @@ -228,6 +256,52 @@ agentengine launch . --env KEY=VALUE --env-file ./runtime.env kept. See [Environment Variables Reference](../references/environment-variables). +### Default observability and explicit opt-out + +Managed Agent `deploy`, `launch`, and `hermes deploy` commands send `enable_observability=true` by default. The CLI and console use the same control-plane switch. When enabled, the platform injects the standard Langfuse OTLP primary route and CloudMonitor OTLP secondary route, preserving the same `trace_id` and `span_id` for each span. + +```bash title="bash" +# Enabled by default +agentengine deploy . --target serverless +agentengine launch . --target serverless +agentengine hermes deploy --name my-hermes + +# Explicit user opt-out +agentengine deploy . --target serverless --no-observability +agentengine launch . --target serverless --no-observability +agentengine hermes deploy --name my-hermes --no-observability +``` + +Disabling observability clears the managed OTLP endpoints, headers, and protocols. Do not use `--env` to override platform-managed observability variables. See the [Environment Variables Reference](../references/environment-variables) for the protocol contract. + +### `agentengine hermes` + +Hermes deploy accepts explicit runtime environment variables and, when +`--env-file` is omitted, automatically reads `.env` from the current directory: + +```bash title="bash" +agentengine hermes deploy --name my-hermes \ + --env LOG_LEVEL=debug \ + --env FEATURE_FLAG=on +agentengine hermes deploy --name my-hermes --env-file ./hermes.env +``` + +`--env` is repeatable and `--env-file` accepts dotenv or a JSON object. The +precedence is `--env` > `--env-file` > process environment > auto-discovered +`.env`. Explicit environment options update `env_vars` on an existing Hermes; +an image-only update does not overwrite the environment already stored by the +service. + +Hermes exec passes argv directly and never parses it through a shell. The supported target forms are: + +```bash title="bash" +agentengine hermes exec --agent my-hermes -- status +agentengine hermes exec ar-123 -- status +agentengine hermes exec -- sessions list +``` + +Use `--agent` to explicitly target an Agent by name; a positional target is retained only for `ar-*` Agent IDs. Without a target, the command uses the project default Agent, and ordinary argv tokens are never guessed to be Agent names. A business session ID passed with `--session` is sent in the terminal start frame (while retaining the compatibility header) so the runtime can resume the session. + ### `agentengine openclaw` Deploy a prebuilt OpenClaw image to the cloud runtime; model configuration is @@ -266,7 +340,10 @@ Memory backend options (0.6.7): - OpenClaw deploy also supports `--env KEY=VALUE` (repeatable) and `--env-file` for runtime environment variable pass-through, with the same semantics as the generic deploy/launch (0.6.5). + OpenClaw deploy also supports repeatable `--env KEY=VALUE`, `--env-file`, and + automatic discovery of `.env` in the current directory, with the same + precedence as Hermes. Generic `deploy` / `launch` only use explicit `--env` + or `--env-file` options and do not automatically load the project `.env`. ## JSON Output diff --git a/docs-site/content/docs/cli/index.mdx b/docs-site/content/docs/cli/index.mdx index f8dfc957..7206ab72 100644 --- a/docs-site/content/docs/cli/index.mdx +++ b/docs-site/content/docs/cli/index.mdx @@ -25,6 +25,7 @@ agentengine --help agentengine init --help agentengine run --help agentengine web --help +agentengine studio --help ``` ## 命令分组 @@ -35,6 +36,7 @@ agentengine web --help | `config` | 管理模型和项目设置 | 是 | | `run` | 运行终端循环或本地 API server | 是 | | `web` | 启动本地浏览器 UI | 是 | +| `studio` | 启动本地 Agent 构建与调试工作区 | 是 | | `a2a` | 在配置后暴露 A2A surface | 是 | | `replay` | 只读回放 RuntimeEvent 历史 | 是 | | `mcp` | 构建或管理 MCP 资源 | 取决于目标 | @@ -130,6 +132,27 @@ agentengine web . --no-open Codex ManagedRuntime 同样使用 `agentengine web . --no-open`。它会校验当前操作系统 对应的本地 Codex 二进制;离线但未显式锁定 Runtime 版本时会给出未锁定提示。 +### `agentengine studio` + +启动只监听 loopback 的本地 Agent 构建工作区: + +```bash title="bash" +agentengine studio . +agentengine studio . --port 8081 --no-open +agentengine studio . --env-file ./model.env --codex-proxy auto +``` + +Studio 使用单一 React shell 承载 Agent、会话、构建、资源、Trace 与任务编排页面,根路径 +直接进入工作区;没有需要单独维护的 `/chat` 前端。模型环境可从当前进程、全局配置或显式 +`--env-file` 解析,浏览器写操作使用本地 session 与 CSRF 双重校验。 + +`studio/static` 是随 Python 包分发、但不进入 Git 的 React 生产构建产物;前端源码与测试位于 +`ksadk/studio/react-ui`。修改源码后使用 `make studio-react-test` 验证前端,发布构建通过 +`make build-wheel` 或 `make public-build-check` 重新生成并打包产物。 + +从启动工作区到创建、构建和测试 Agent 的完整流程见 +[AgentKit Local Studio(0.8.1 新增)](/cn/docs/framework/guides/agentkit-local-studio)。 + ### `ksadk replay` / `agentengine replay` 从一个 session 的新 RuntimeEvent 日志生成可读 transcript 或 JSON,适合排查断线、刷新后的 @@ -214,6 +237,8 @@ agentengine launch . --env KEY=VALUE --env-file ./runtime.env | `--env KEY=VALUE` | 额外运行时环境变量,可重复传入;显式 env 进入部署 payload `env_vars` | | `--env-file PATH` | 额外运行时环境变量文件,支持 `.env` 或 JSON 对象 | +敏感凭证(包括通过 `--env` 传入的密钥)会在 `--dry-run` 输出中脱敏。 + 传入的 `--env-file` 用于显式注入运行时 env。真实的 `.env` / `.env.local` 不会进入 Code、Container 或 MCP 构建上下文,构建只保留 `.env.example` / @@ -221,6 +246,49 @@ agentengine launch . --env KEY=VALUE --env-file ./runtime.env [环境变量参考](../references/environment-variables)。 +### 默认可观测性与显式关闭 + +托管 Agent 的 `deploy`、`launch` 和 `hermes deploy` 默认发送 `enable_observability=true`。CLI 与控制台使用同一个控制面开关;平台在开启时注入 Langfuse 标准 OTLP 主路和 CloudMonitor OTLP 次路,同一 span 在两端保持相同的 `trace_id` / `span_id`。 + +```bash title="bash" +# 默认开启 +agentengine deploy . --target serverless +agentengine launch . --target serverless +agentengine hermes deploy --name my-hermes + +# 用户显式关闭 +agentengine deploy . --target serverless --no-observability +agentengine launch . --target serverless --no-observability +agentengine hermes deploy --name my-hermes --no-observability +``` + +关闭后平台会清理托管 OTLP endpoint、headers 和 protocol;不要用 `--env` 覆盖平台托管的可观测变量。环境变量协议见[环境变量参考](../references/environment-variables)。 + +### `agentengine hermes` + +Hermes deploy 支持显式运行时环境变量,也会在未指定 `--env-file` 时自动读取当前目录 `.env`: + +```bash title="bash" +agentengine hermes deploy --name my-hermes \ + --env LOG_LEVEL=debug \ + --env FEATURE_FLAG=on +agentengine hermes deploy --name my-hermes --env-file ./hermes.env +``` + +`--env` 可重复传入,`--env-file` 支持 dotenv 或 JSON 对象。优先级为 +`--env` > `--env-file` > 当前进程环境 > 自动发现的 `.env`。显式环境参数会更新 +已有 Hermes 的 `env_vars`;未显式传入模型或环境参数时,镜像更新不会覆盖服务端现有环境配置。 + +Hermes exec 使用 argv 透传,不经过 shell 解析。支持以下目标形式: + +```bash title="bash" +agentengine hermes exec --agent my-hermes -- status +agentengine hermes exec ar-123 -- status +agentengine hermes exec -- sessions list +``` + +`--agent` 用于显式指定名称;位置参数只兼容 `ar-*` Agent ID。未指定目标时,命令会使用当前项目的默认 Agent,普通 argv token 不会被猜测为 Agent 名称。传入 `--session` 的业务 session ID 会同时写入 terminal start frame(并保留兼容 header),以便运行时恢复会话。 + ### `agentengine openclaw` 部署预构建的 OpenClaw 镜像到云端运行时,模型配置自动复用 `OPENAI_*` 环境变量。 @@ -258,7 +326,9 @@ agentengine openclaw --help - OpenClaw 部署同样支持 `--env KEY=VALUE`(可重复)和 `--env-file` 透传运行时环境变量,语义与通用 deploy/launch 一致(0.6.5)。 + OpenClaw 部署同样支持可重复的 `--env KEY=VALUE`、`--env-file` 和当前目录 + `.env` 自动发现,并采用与 Hermes 相同的优先级。通用 `deploy` / `launch` + 只读取显式传入的 `--env` / `--env-file`,不会自动加载项目 `.env`。 ## JSON 输出 diff --git a/docs-site/content/docs/framework/guides/agentkit-local-studio.en.mdx b/docs-site/content/docs/framework/guides/agentkit-local-studio.en.mdx new file mode 100644 index 00000000..64de336e --- /dev/null +++ b/docs-site/content/docs/framework/guides/agentkit-local-studio.en.mdx @@ -0,0 +1,81 @@ +--- +title: AgentKit Local Studio +description: The local Agent authoring, build, and conversation workspace added in 0.8.1. +status: new +--- + + + AgentKit Local Studio is a local-first workspace for authoring, building, and testing Agents in the browser. End users do not need a separate Node.js installation. + + +`agentengine studio` is KsADK's local-first workspace. It brings Agent definitions, build history, conversations, resources, traces, and orchestration into one browser surface, while model calls and builds still run through the local KsADK runtime. + +It does not replace `agentengine web`: use the Web UI to debug one existing project; use Studio to create and maintain several local Agents from an initial requirement. + +## Start a workspace + +After installing KsADK, start Studio in a directory that will hold your local Agents: + +```bash title="shell" +pip install -U "ksadk[all]" +mkdir my-agent-workspace +agentengine studio ./my-agent-workspace +``` + +Studio binds to `127.0.0.1:8080` by default. The command prints a local session URL and opens it in the browser. That URL is for local use only: do not share its `#session=...` fragment. + +```bash title="shell" +# Do not open a browser, and use a different port +agentengine studio ./my-agent-workspace --port 8081 --no-open + +# Provide model settings for this launch +agentengine studio ./my-agent-workspace --env-file ./model.env +``` + +`--env-file` reads only `OPENAI_API_BASE`, `OPENAI_API_KEY`, and `OPENAI_MODEL_NAME`. Existing process environment values take precedence and are not overwritten. When a Codex project needs Responses-to-Chat compatibility conversion, use `--codex-proxy auto`; see the full options in the [CLI reference](/en/docs/cli#agentengine-studio). + + + 0.8.1 does not include cloud deployment or multi-user collaboration through Studio. To deploy an existing project, use `agentengine deploy` in that project directory and follow the [cloud deployment](cloud-deployment) guide. + + +## Create, build, and chat + +In Studio's **Create Agent** flow, choose a runtime, template, and model profile, then provide the Agent name and task. The runtimes currently available for local creation and execution are: + +- **Codex**: a declarative YAML Agent for debugging with the native local Codex Runtime. +- **ADK**: a Python Agent that exports `root_agent`. +- **LangGraph**: a Python Agent that exports a compiled graph. + +The flow creates an editable Agent definition and can build it immediately. After a successful build, open the Agent's conversation view to send a message and inspect its response, run history, and traces in the same workspace. Model credentials stay in the local environment or explicit configuration file; they are not written into the Agent definition. + +You can also keep using CLI templates and then debug the generated project in the local Web UI: + +```bash title="shell" +ksadk init --framework langgraph my-langgraph-agent +cd my-langgraph-agent +agentengine web . +``` + +For generated files, runtime entry-point conventions, and deployment paths, see [Create a project](../getting-started/quickstart), [Codex Managed Runtime](managed-runtime), and [cloud deployment](cloud-deployment). + +## Studio and the local Web UI + +| Scenario | Command | Best for | +| --- | --- | --- | +| Create or maintain several local Agents | `agentengine studio ` | authoring, builds, resources, conversations, traces, and orchestration | +| Debug an existing KsADK project | `agentengine web ` | messages, local sessions, attachments, and the OpenAI-compatible API | +| Quickly validate a project from the terminal | `agentengine run -i` | interactive execution and problem isolation | + +Both Studio and `agentengine web` bind to loopback only; neither browser UI calls a model provider directly. Browser writes are checked with local session and CSRF tokens, then the local runtime invokes the configured framework runner. + +## Local state and reset + +Workspace data and conversations are local development state, not source code to commit. To clear a local experiment, stop Studio and remove `.agentengine/` inside that workspace before starting it again. + +Do not remove Agent source files or `agentengine.yaml` to reset conversations. Those files define the project and should remain under version control. + +## UI package and source + +Wheel users do not need to build the Studio frontend: the wheel includes the production static assets. Studio contributors work in the tracked `ksadk/studio/react-ui` source directory. The generated `ksadk/studio/static` directory is not committed, but is generated and packaged automatically when a wheel is built. + +See [build and package](build-and-package) and the [Web UI source and release contract](web-ui-source) for details. diff --git a/docs-site/content/docs/framework/guides/agentkit-local-studio.mdx b/docs-site/content/docs/framework/guides/agentkit-local-studio.mdx new file mode 100644 index 00000000..c4c9a660 --- /dev/null +++ b/docs-site/content/docs/framework/guides/agentkit-local-studio.mdx @@ -0,0 +1,81 @@ +--- +title: AgentKit Local Studio +description: 0.8.1 新增的本地 Agent 创作、构建与对话工作区。 +status: new +--- + + + AgentKit Local Studio 是面向本地开发的 Agent 创作工作区:在浏览器中创建、构建并测试 Agent,不需要另行安装 Node.js。 + + +`agentengine studio` 是 KsADK 的本地优先工作区。它把 Agent 定义、构建记录、会话、资源、Trace 与任务编排放在同一个浏览器界面中;模型调用和构建仍由本机 KsADK 运行时执行。 + +它不是 `agentengine web` 的替代品:后者用于调试一个已经存在的项目;Studio 用于从需求开始创建和维护多个本地 Agent。 + +## 启动工作区 + +安装 KsADK 后,在一个准备存放本地 Agent 的目录启动 Studio: + +```bash title="shell" +pip install -U "ksadk[all]" +mkdir my-agent-workspace +agentengine studio ./my-agent-workspace +``` + +默认监听 `127.0.0.1:8080`,命令会打印带本地 session 的访问地址并自动打开浏览器。这个地址只适合本机使用;不要把其中的 `#session=...` 片段分享给其他人。 + +```bash title="shell" +# 不自动打开浏览器,并改用其他端口 +agentengine studio ./my-agent-workspace --port 8081 --no-open + +# 为本次启动提供模型配置文件 +agentengine studio ./my-agent-workspace --env-file ./model.env +``` + +`--env-file` 只读取 `OPENAI_API_BASE`、`OPENAI_API_KEY` 和 `OPENAI_MODEL_NAME`。已有的进程环境变量优先,不会被该文件覆盖。Codex 项目需要 Responses-to-Chat 兼容转换时,可使用 `--codex-proxy auto`;完整选项见[命令行参考](/cn/docs/cli#agentengine-studio)。 + + + 0.8.1 不包含 Studio 的云端部署或多人协作能力。需要部署已有项目时,请使用项目目录中的 `agentengine deploy`,并遵循[云端部署](cloud-deployment)文档。 + + +## 创建、构建并对话 + +在 Studio 的 **创建 Agent** 向导中选择运行时、模板和模型配置,填写 Agent 名称与任务说明。当前可创建并在本机运行的运行时包括: + +- **Codex**:声明式 YAML Agent;适合使用本机 Codex Runtime 调试。 +- **ADK**:导出 `root_agent` 的 Python Agent。 +- **LangGraph**:导出编译图的 Python Agent。 + +向导会生成可编辑的 Agent 定义;可选择在创建后立即构建。构建完成后,从该 Agent 的会话页发送消息,即可在同一工作区检查回复、运行记录和 Trace。模型凭据只从本机环境或显式配置文件读取,不会写进 Agent 定义。 + +如果你已有 KsADK 项目,也可以继续使用 CLI 模板创建后再用本地 Web UI 调试: + +```bash title="shell" +ksadk init --framework langgraph my-langgraph-agent +cd my-langgraph-agent +agentengine web . +``` + +有关模板生成的文件、各运行时的入口约定和部署方式,请分别参阅[创建项目](../getting-started/quickstart)、[Codex Managed Runtime](managed-runtime)和[云端部署](cloud-deployment)。 + +## Studio 与本地 Web UI 的分工 + +| 场景 | 使用的命令 | 适合做什么 | +| --- | --- | --- | +| 从零创建或维护多个本地 Agent | `agentengine studio ` | 创作、构建、资源管理、会话、Trace 与编排 | +| 调试一个已有的 KsADK 项目 | `agentengine web ` | 发送消息、检查本地会话、附件和 OpenAI 兼容 API | +| 在终端快速验证一个项目 | `agentengine run -i` | 交互式运行与问题隔离 | + +Studio 与 `agentengine web` 都只绑定 loopback;它们不会把浏览器直接连到模型 provider。浏览器写操作由本地 session 和 CSRF token 校验,本机运行时再调用配置的框架 Runner。 + +## 本地状态与重置 + +Studio 的工作区数据和会话是本地开发状态,不是需要提交的源代码。若只是想清理一次本地实验,可停止 Studio 后删除该工作区中的 `.agentengine/`,再重新启动。 + +不要删除你的 Agent 源码或 `agentengine.yaml` 来重置会话;它们是项目定义,应正常纳入版本控制。 + +## UI 包与源码 + +安装 wheel 的用户不需要构建 Studio 前端:wheel 已包含生产静态资源。贡献 Studio UI 时,受跟踪的源码位于 `ksadk/studio/react-ui`;生成到 `ksadk/studio/static` 的文件不会进入 Git,但会在 wheel 构建时自动生成并打包。 + +详见[构建与打包](build-and-package)和[Web UI 源码与发布契约](web-ui-source)。 diff --git a/docs-site/content/docs/framework/guides/build-and-package.en.mdx b/docs-site/content/docs/framework/guides/build-and-package.en.mdx index d2d1349c..500ffbbc 100644 --- a/docs-site/content/docs/framework/guides/build-and-package.en.mdx +++ b/docs-site/content/docs/framework/guides/build-and-package.en.mdx @@ -14,12 +14,14 @@ make public-review ## Web UI Artifacts -The `ksadk-python` wheel ships `ksadk/server/static`, so users can open the -local UI without a Node toolchain. +The `ksadk-python` wheel ships `ksadk/server/static` and +`ksadk/studio/static`, so users can open both local UIs without a Node +toolchain. -Editable UI source is not part of `ksadk-python`; it lives in the `ksadk-web` -repo only. +The hosted web UI source lives in the `ksadk-web` repo. The Studio source is +the tracked `ksadk/studio/react-ui` project in this repository; only its +compiled payload is ignored. ## Local Project Build @@ -47,15 +49,17 @@ agentengine --dry-run deploy . ## Release Gate -`make public-build-check` runs wheel-content assertions between `uv build` and -`twine check dist/*`, so a clean build is enforced before any upload. +`make public-build-check` is the release build entrypoint. It generates both +frontend payloads, then runs `uv build`, wheel/sdist content assertions, and +`twine check dist/*`. 1. **clean-dist** clears the previous `dist/` 2. **sync-ksadk-web-static** fetches the pinned, published UI static assets - 3. **uv build** builds the sdist/wheel - 4. **tests/test_runtime_common_packaging.py** inspects wheel contents - 5. **twine check dist/*** final check + 3. **build-studio-static** installs the locked Studio dependencies and builds its ignored static payload + 4. **uv build** builds the sdist/wheel + 5. **tests/test_runtime_common_packaging.py** inspects wheel and sdist contents + 6. **twine check dist/*** final check The assertions guarantee: @@ -64,8 +68,9 @@ The assertions guarantee: outputs, or `node_modules/`. - the wheel does not contain historical build residue (stale `dist/` fragments, `.zread/`, `.pypirc`). -- the wheel includes the synced `ksadk/server/static/index.html` and its - `assets/` entry, so the local UI opens right after install. +- the wheel includes the synced `ksadk/server/static/index.html` and generated + `ksadk/studio/static/index.html`, with their `assets/` entries, so both local + UIs open right after install. `make public-preflight` layers `public-audit`, `public-test`, and @@ -86,15 +91,17 @@ Release artifacts must not contain: - customer data, traces, logs, or screenshots. -The Python package may include static UI assets needed by `agentengine web`. -Editable UI source belongs to `ksadk-web`. +The Python package may include static UI assets needed by `agentengine web` and +Studio. The hosted web UI source belongs to `ksadk-web`; Studio source remains +tracked in `ksadk/studio/react-ui`. Official PyPI releases run through `.github/workflows/publish-pypi.yml`, triggered by a GitHub Release `published` event or `workflow_dispatch`. The -workflow first runs `make sync-ksadk-web-static` (default -the pinned published `@kingsoftcloud/ksadk-web` version; select another published version via the `ksadk_web_version` -input), then `make public-preflight`, and finally uploads through OIDC Trusted +workflow runs `make public-preflight`, which synchronizes the pinned +`ksadk-web` static payload and generates the Studio payload (by default from +the published `@kingsoftcloud/ksadk-web` version; select another published +version via the `ksadk_web_version` input), then uploads through OIDC Trusted Publishing without relying on a long-lived PyPI token. diff --git a/docs-site/content/docs/framework/guides/build-and-package.mdx b/docs-site/content/docs/framework/guides/build-and-package.mdx index 8562ed7b..74ad5075 100644 --- a/docs-site/content/docs/framework/guides/build-and-package.mdx +++ b/docs-site/content/docs/framework/guides/build-and-package.mdx @@ -13,10 +13,11 @@ make public-review ## Web UI 产物 -`ksadk-python` wheel 包含 `ksadk/server/static`,保证用户安装后无需 Node 也能打开本地 UI。 +`ksadk-python` wheel 包含 `ksadk/server/static` 和 `ksadk/studio/static`,保证用户安装后无需 Node 也能打开两类本地 UI。 -可编辑 UI 源码不进入 `ksadk-python`,只在 `ksadk-web` 仓库维护。 +Hosted Web UI 的可编辑源码在 `ksadk-web` 仓库维护。Studio 源码是本仓库受跟踪的 +`ksadk/studio/react-ui`;只有它的编译产物被 Git 忽略。 ## 本地项目构建 @@ -41,21 +42,22 @@ agentengine --dry-run deploy . ## 发布门禁 -`make public-build-check` 在 `uv build` 与 `twine check dist/*` 之间执行 wheel 内容检查,确保发布产物干净可用。 +`make public-build-check` 是发布构建入口:它会生成两类前端产物,再执行 `uv build`、wheel/sdist 内容检查和 `twine check dist/*`,确保发布产物干净可用。 1. **clean-dist** 清空旧 `dist/` - 2. **sync-ksadk-web-static** 拉取最新 UI 静态产物 - 3. **uv build** 构建 sdist/wheel - 4. **tests/test_runtime_common_packaging.py** 校验 wheel 内容 - 5. **twine check dist/*** 最终校验 + 2. **sync-ksadk-web-static** 拉取固定版本的 Web UI 静态产物 + 3. **build-studio-static** 安装锁定的 Studio 依赖并构建被 Git 忽略的静态产物 + 4. **uv build** 构建 sdist/wheel + 5. **tests/test_runtime_common_packaging.py** 校验 wheel 与 sdist 内容 + 6. **twine check dist/*** 最终校验 该检查会断言: - wheel 不含旧 `ksadk/server/web-ui/` 源码、构建产物与 `node_modules/`。 - wheel 不含历史构建残留(如上一次 `dist/` 残片、`.zread/`、`.pypirc`)。 -- wheel 含同步后的 `ksadk/server/static/index.html` 及 `assets/` 入口,保证安装即可打开本地 UI。 +- wheel 含同步后的 `ksadk/server/static/index.html` 与生成的 `ksadk/studio/static/index.html` 及其 `assets/` 入口,保证安装即可打开本地 UI。 `make public-preflight` 在 `public-build-check` 之上追加 `public-audit`、`public-test`、`docs-site-build`,是推 GitHub/PyPI/Release 前必须通过的完整本地门禁。 @@ -74,10 +76,11 @@ agentengine --dry-run deploy . - 客户数据、trace、日志或截图。 -Python 包可包含 `agentengine web` 需要的静态 UI 产物;可编辑 UI 源码属于 `ksadk-web`。 +Python 包可包含 `agentengine web` 和 Studio 需要的静态 UI 产物;Hosted Web UI 源码属于 +`ksadk-web`,Studio 源码仍受 `ksadk/studio/react-ui` 跟踪。 -正式 PyPI 发布走 `.github/workflows/publish-pypi.yml`,由 GitHub Release `published` 事件或 `workflow_dispatch` 触发;workflow 先 `make sync-ksadk-web-static`(0.8.0 默认拉取固定的已发布 `@kingsoftcloud/ksadk-web@0.3.0`,可通过 `ksadk_web_version` input 指定一个已发布版本),再 `make public-preflight`,最后通过 OIDC Trusted Publishing 上传,不依赖长期 PyPI token。同步与构建会比较 npm tarball 中 `dist-ksadk`、`ksadk/server/static` 和 wheel 内静态文件的完整路径与内容哈希;任一不一致都会拒绝构建。 +正式 PyPI 发布走 `.github/workflows/publish-pypi.yml`,由 GitHub Release `published` 事件或 `workflow_dispatch` 触发;workflow 执行 `make public-preflight`,其中会同步固定的已发布 `@kingsoftcloud/ksadk-web@0.3.1`(可通过 `ksadk_web_version` input 指定一个已发布版本)并构建 Studio 静态产物,最后通过 OIDC Trusted Publishing 上传,不依赖长期 PyPI token。同步与构建会比较 npm tarball 中 `dist-ksadk`、`ksadk/server/static` 和 wheel 内静态文件的完整路径与内容哈希;任一不一致都会拒绝构建。 Serverless 部署会在运行时 Pod 注入 UI 配置环境变量:`KSADK_UI_PROFILE`、`KSADK_UI_PATH`、`KSADK_UI_URL`、`KSADK_UI_BUNDLE_PATH`。Pod 内 `ksadk.server.app` 读取这些变量还原 UI 运行时配置,无需把本地 `.agentengine/` 状态打包进镜像。 diff --git a/docs-site/content/docs/framework/guides/memory-knowledge.en.mdx b/docs-site/content/docs/framework/guides/memory-knowledge.en.mdx index 6d2f09f1..d0d48f08 100644 --- a/docs-site/content/docs/framework/guides/memory-knowledge.en.mdx +++ b/docs-site/content/docs/framework/guides/memory-knowledge.en.mdx @@ -58,6 +58,8 @@ Long-term memory is accessed through a small backend contract: The runtime service chooses a backend from environment configuration, formats results into prompt text, and exposes framework tools such as `load_memory` and `save_memory`. If no long-term memory backend is configured, the public runtime should continue to work normally. +For the SDK memory backend, an explicit `save_memory` tool call forces extraction of the submitted data so that a later lookup can observe it promptly. Automatic per-turn persistence retains the backend's default batching behavior. + Backend options can include local test stores, HTTP services, or cloud SDK integrations. For open-source examples, document the interface first and make hosted backends optional. @@ -98,6 +100,17 @@ def search_documents(query: str, top_k: int = 3) -> list[dict[str, str]]: Hosted knowledge integrations can follow the same visible shape: a query enters the retriever, the retriever returns scored snippets, and the runner receives a short formatted context block. Keep raw private document IDs, account IDs, and dataset names out of public examples. +## Managed Hermes Integration + +The Hermes image consumes the same platform bindings through a KsADK memory provider and knowledge tools without modifying upstream Hermes source code: + +- Long-term memory uses only stable user, session, and Agent identities supplied by the platform. Missing identity or service bindings fail closed; identity is never inferred from model text. +- A session switch prefetches relevant memory into an isolated cache. Prompt injection reads only the current session cache, and an in-flight result from an older session cannot be published into the new session. +- An explicit save waits for the bound service to complete `save_event_strings` and extraction before returning. Operations already in flight remain attached to their original user and session during reconfiguration. +- Knowledge tools query the current platform binding and format the result. Retrieval, formatting, or service failures return a generic diagnostic without exposing credentials, internal endpoints, or raw exceptions to the model or logs. + +These integrations are optional. A Hermes Agent continues to chat and use unrelated tools when no knowledge base or long-term memory service is bound. + ## ADK Versus Other Frameworks ADK projects can receive memory and knowledge through ADK-compatible tools and, when configured, an ADK memory service. LangGraph, LangChain, and DeepAgents projects should usually receive context through the runner payload or explicit hook functions. diff --git a/docs-site/content/docs/framework/guides/memory-knowledge.mdx b/docs-site/content/docs/framework/guides/memory-knowledge.mdx index 7f683a06..35feca46 100644 --- a/docs-site/content/docs/framework/guides/memory-knowledge.mdx +++ b/docs-site/content/docs/framework/guides/memory-knowledge.mdx @@ -76,8 +76,20 @@ def load_user_memory(query: str) -> str: 推荐保存短事实,例如「发布说明先写结论,再写风险和验证方式」。 +SDK 记忆后端中,用户显式调用 `save_memory` 会强制当前数据抽取,以便后续检索;自动轮次保存仍保留后端默认的批处理语义。 +## Hermes 托管接入 + +Hermes 镜像通过 KsADK memory provider 和知识库工具消费同一套平台绑定,不要求修改 Hermes 上游源码: + +- 长期记忆只使用平台下发的稳定 user/session/agent 标识;缺少稳定标识或服务绑定时 fail closed,不从模型文本猜测用户身份。 +- session 切换时预取相关记忆并写入隔离缓存,prompt 注入只读取当前 session 的缓存;并发切换不会把旧 session 的结果发布到新 session。 +- 显式保存等待当前服务完成 `save_event_strings`/抽取后再返回;服务重建或 session 切换期间,已开始的操作继续绑定原始 user/session。 +- 知识库工具通过当前平台绑定执行检索并格式化结果;检索、格式化或服务异常返回通用诊断,不向模型或日志暴露凭证、内部 endpoint 和原始异常。 + +这些能力是可选增强。未绑定知识库或长期记忆时,Hermes Agent 仍可正常对话和使用其他工具。 + ## 环境变量 常见变量: diff --git a/docs-site/content/docs/framework/guides/meta.json b/docs-site/content/docs/framework/guides/meta.json index e1fca7f9..5374e2c0 100644 --- a/docs-site/content/docs/framework/guides/meta.json +++ b/docs-site/content/docs/framework/guides/meta.json @@ -4,6 +4,7 @@ "frameworks", "harness-app", "local-web-ui", + "agentkit-local-studio", "hosted-ui-events", "agent-context", "attachments-multimodal", diff --git a/docs-site/content/docs/framework/guides/observability-tracing.en.mdx b/docs-site/content/docs/framework/guides/observability-tracing.en.mdx index 2e2d3b34..726b4907 100644 --- a/docs-site/content/docs/framework/guides/observability-tracing.en.mdx +++ b/docs-site/content/docs/framework/guides/observability-tracing.en.mdx @@ -2,7 +2,7 @@ title: "Observability And Tracing" --- -KsADK includes local spans, a Langfuse-compatible path, and a standard OTLP HTTP traces exporter. Tracing is optional diagnostics and should not be required for the quickstart path. +KsADK includes local spans and a standard OTLP HTTP traces exporter. Tracing is optional diagnostics and should not be required for the quickstart path. Managed Agents created from either the CLI or console request observability by default and receive the Langfuse primary and CloudMonitor secondary routes from the platform. Use `--no-observability`, or turn observability off in the console, to disable it explicitly. Starting in `0.6.2`, the recommended model is **OTel-first**: application code creates OpenTelemetry spans, span events, and attributes, while `OTEL_EXPORTER_OTLP_*` environment variables route data to Langfuse, an OTel Collector, or another compatible backend. @@ -15,8 +15,8 @@ The local in-memory exporter is the safest public default. Local runs should con | Path | Default behavior | Purpose | | --- | --- | --- | | In-memory spans | enabled for local runner paths | local debug APIs and Web UI trace views | -| Generic OTLP HTTP | enabled when `OTEL_EXPORTER_OTLP_*` is set | send spans to Langfuse or any OTLP HTTP Collector | -| Langfuse OTLP HTTP | enabled when no generic OTLP exists and Langfuse keys are set | compatibility with older Langfuse env vars | +| Langfuse OTLP HTTP (primary) | local: enabled when `OTEL_EXPORTER_OTLP_*` is set; managed: injected by default | send to Langfuse or any OTLP HTTP backend | +| CloudMonitor OTLP HTTP (secondary) | local: enabled when `CLOUD_MONITOR_OTLP_*` is set; managed: injected by default | platform extension, dual-write to the Cloud Monitor trace platform | | OTLP gRPC | explicit `enable_otlp=True` | compatibility with older code paths | | OpenInference instrumentation | best effort | framework spans for ADK, LangChain, and similar runtimes | @@ -48,18 +48,6 @@ export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer%20trace-token" Headers follow the OTLP comma-separated format. URL-encode header values; KsADK decodes `Bearer%20trace-token` to `Bearer trace-token`. -## Langfuse Compatibility - -If a project still uses Langfuse environment variables, the existing setup continues to work: - -```bash title="env.sh" -export LANGFUSE_BASE_URL="https://langfuse.example.com" - -agentengine run . -``` - -When generic OTLP and Langfuse variables are both present, `setup_tracing(enable_langfuse=None)` prefers generic OTLP and does not enable a second Langfuse direct exporter. This avoids duplicate traces in Langfuse. Explicit `setup_tracing(enable_langfuse=True)` can still force the Langfuse compatibility path. - To use Langfuse as an OTLP backend, configure standard OTLP variables: ```bash title="env.sh" @@ -72,51 +60,47 @@ export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Basic%20 -## Callback-only Mode +## OTLP Dual-Write + +KsADK converges to one TracerProvider + two OTLP/HTTP BatchSpanProcessors per Python Agent process: Langfuse primary (standard `OTEL_EXPORTER_OTLP_*`) + CloudMonitor secondary (platform extension `CLOUD_MONITOR_OTLP_*`). The Langfuse CallbackHandler path has been removed; traces are no longer reported via the Langfuse SDK. -LangChain or LangGraph projects may prefer the Langfuse callback handler instead of direct OTLP. Enable callback-only mode explicitly: +Both exporters run in the Agent process and connect directly to their OTLP endpoints. This does not deploy an OpenTelemetry Collector, sidecar, extra container, or extra Pod. ```bash title="env.sh" -export LANGFUSE_BASE_URL="https://langfuse.example.com" -export LANGFUSE_USE_CALLBACK="true" +# Primary (Langfuse OTLP) +export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://langfuse.example.com/api/public/otel/v1/traces" +export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Basic%20,x-langfuse-ingestion-version=4" + +# Secondary (CloudMonitor OTLP, injected by platform) +export CLOUD_MONITOR_OTLP_TRACES_ENDPOINT="https://cloudmonitor.example.com/v1/traces" +export CLOUD_MONITOR_OTLP_HEADERS="Ksc-Appkey=" agentengine run . ``` - -Choose either callback or direct OTLP for a run unless duplicate traces have been reviewed and accepted. + +Header values use RFC 3986 percent-encoding (space `%20` not `+`). KsADK decodes them to real header values before passing to the OTLP exporter. -## Cloud Monitor Dual-Write (0.6.7 new) +## Cloud Monitor OTLP - -`setup_tracing()` auto-detects `CLOUD_MONITOR_*` environment variables without suppressing a self-hosted Langfuse or standard OTLP backend. A single span can be reported simultaneously to a self-hosted observability cluster and the Cloud Monitor trace platform. - - -When `setup_tracing()` initializes, it inspects `CLOUD_MONITOR_*` variables: if `CLOUD_MONITOR_OTLP_ENDPOINT` or `CLOUD_MONITOR_LANGFUSE_HOST` is set, it builds a Cloud Monitor OTLP exporter and a Langfuse SDK callback respectively, writing in parallel with the self-hosted Langfuse or standard `OTEL_EXPORTER_OTLP_*` paths. The three paths are detected independently and do not suppress one another, so a single span can land in both a self-hosted cluster and the Cloud Monitor trace platform. +`setup_tracing()` auto-detects `CLOUD_MONITOR_OTLP_*` variables and builds a CloudMonitor OTLP exporter writing in parallel with the primary Langfuse OTLP. A single span enters both backends with the same `trace_id` and `span_id`. Traces-specific endpoint, protocol, and headers each take precedence over their generic values. The Langfuse CallbackHandler path has been removed. | Path | Auth | Protocol | Trigger | | --- | --- | --- | --- | -| CloudMonitor OTLP exporter | `Ksc-Appkey` header | `http/protobuf` | `CLOUD_MONITOR_OTLP_ENDPOINT` set and `CLOUD_MONITOR_OTLP_ENABLED` not explicitly `false` | -| CloudMonitor Langfuse SDK CallbackHandler | independent TracerProvider, Langfuse public/secret key | Langfuse SDK callback | triggered by `LANGFUSE_USE_CALLBACK=true`, or explicit `CLOUD_MONITOR_LANGFUSE_ENABLED=true` | +| CloudMonitor OTLP exporter | `Ksc-Appkey` header | `http/protobuf` | endpoint configured and traces-specific or generic headers provide `Ksc-Appkey` | -The CloudMonitor OTLP exporter injects the AppKey via a `Ksc-Appkey` header with a fixed `http/protobuf` protocol; the trace token is collected and backfilled onto the root span so a full invocation can be aggregated on the Cloud Monitor side. The CloudMonitor Langfuse SDK callback handler attaches to an independent TracerProvider so it does not interfere with the application TracerProvider. - -Related environment variables (11 in total, see `../reference/environment-variables.en.md` Observability section): +Related environment variables (see `../reference/environment-variables.en.md` Observability section): | Variable | Sensitive | Description | | --- | --- | --- | -| `CLOUD_MONITOR_APP_KEY` | yes | Cloud Monitor OTLP AppKey used for `Ksc-Appkey` auth. | -| `CLOUD_MONITOR_OTLP_ENABLED` | no | Explicitly enable or disable the CloudMonitor OTLP exporter; defaults to auto-detection. | -| `CLOUD_MONITOR_OTLP_ENDPOINT` | no | Cloud Monitor generic OTLP HTTP endpoint; derives `/v1/traces` when no traces-specific endpoint is set. | -| `CLOUD_MONITOR_OTLP_PROTOCOL` | no | Cloud Monitor generic OTLP protocol; currently supports `http/protobuf`. | -| `CLOUD_MONITOR_OTLP_HEADERS` | yes | Extra CloudMonitor OTLP headers, comma-separated and URL encoded. | -| `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | no | Cloud Monitor traces-specific endpoint; takes precedence over the generic endpoint. | -| `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | no | Cloud Monitor traces-specific protocol; takes precedence over the generic protocol. | -| `CLOUD_MONITOR_LANGFUSE_ENABLED` | no | Explicitly enable or disable the CloudMonitor Langfuse SDK callback; defaults to auto-detection. | -| `CLOUD_MONITOR_LANGFUSE_HOST` | no | Cloud Monitor AppMonitor Langfuse SDK host; falls back to `CLOUD_MONITOR_OTLP_ENDPOINT` when unset. | -| `CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY` | yes | Cloud Monitor AppMonitor Langfuse public key. | -| `CLOUD_MONITOR_LANGFUSE_SECRET_KEY` | yes | Cloud Monitor AppMonitor Langfuse secret key. | +| `CLOUD_MONITOR_APP_KEY` | yes | **Deprecated** transition fallback; used as `Ksc-Appkey` only when both `CLOUD_MONITOR_OTLP_TRACES_HEADERS` and `CLOUD_MONITOR_OTLP_HEADERS` are entirely absent. | +| `CLOUD_MONITOR_OTLP_ENDPOINT` | no | CloudMonitor generic OTLP HTTP endpoint; derives `/v1/traces` when no traces-specific endpoint is set. | +| `CLOUD_MONITOR_OTLP_PROTOCOL` | no | CloudMonitor generic OTLP protocol; currently supports `http/protobuf`. | +| `CLOUD_MONITOR_OTLP_HEADERS` | yes | CloudMonitor OTLP extra headers, comma-separated and RFC 3986 encoded. | +| `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | no | CloudMonitor traces-specific endpoint; takes precedence over the generic endpoint. | +| `CLOUD_MONITOR_OTLP_TRACES_HEADERS` | yes | CloudMonitor traces-specific headers; take precedence over generic headers and fail closed when present without `Ksc-Appkey`. | +| `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | no | CloudMonitor traces-specific protocol; takes precedence over the generic protocol. | Variables marked "yes" are platform secrets or may carry auth material. Do not commit them to a public `.env` or image environment. Use placeholder values or distribute them through a platform secret service. @@ -126,13 +110,7 @@ Minimal configuration example (placeholder values; replace with your own endpoin ```bash title="env.sh" export CLOUD_MONITOR_OTLP_ENDPOINT="https://cloudmonitor.example.com" -export CLOUD_MONITOR_APP_KEY="cm-appkey-placeholder" - -# Optional: CloudMonitor Langfuse callback -export LANGFUSE_USE_CALLBACK="true" -export CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY="pk-cm" -export CLOUD_MONITOR_LANGFUSE_SECRET_KEY="cm-secret" -export CLOUD_MONITOR_LANGFUSE_HOST="https://cloudmonitor.example.com" +export CLOUD_MONITOR_OTLP_HEADERS="Ksc-Appkey=cm-appkey-placeholder" agentengine run . ``` @@ -195,11 +173,10 @@ Do not put raw prompts, credentials, private URLs, customer names, or uploaded f | --- | --- | --- | | Local trace view is empty | no run has produced spans, or tracing was disabled | run one request and confirm local tracing is enabled | | Generic OTLP receives no data | endpoint, TLS, auth, or Collector policy mismatch | check `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` and headers | -| Langfuse receives no spans | missing key, wrong base URL, callback-only mode, or generic OTLP precedence | check Langfuse vars, `LANGFUSE_USE_CALLBACK`, and `OTEL_EXPORTER_OTLP_*` | -| Duplicate Langfuse traces | callback and direct OTLP are both enabled | choose one export path for the project | +| Langfuse receives no spans | missing auth or wrong OTLP endpoint | check `OTEL_EXPORTER_OTLP_TRACES_HEADERS` and `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | +| Cloud Monitor receives no spans | the selected traces/generic headers lack `Ksc-Appkey`, the endpoint is missing, or the protocol is unsupported | check traces-specific headers first, then generic headers and endpoint; the legacy AppKey fallback applies only when both header variables are unset | | Score is not shown as a native score | the backend does not map `score.*` attributes | add conversion in the Collector or platform service | | Framework spans are sparse | optional instrumentation is missing or inactive | install tracing extras and check framework instrumentation | -| Cloud Monitor receives no spans | `CLOUD_MONITOR_APP_KEY`/`CLOUD_MONITOR_OTLP_ENDPOINT` missing, or protocol is not `http/protobuf` | check `CLOUD_MONITOR_OTLP_ENABLED` is not explicitly `false`, endpoint derives `/v1/traces`, and the `Ksc-Appkey` header is injected | ## Public Documentation Rules diff --git a/docs-site/content/docs/framework/guides/observability-tracing.mdx b/docs-site/content/docs/framework/guides/observability-tracing.mdx index e4f56804..54c7d52b 100644 --- a/docs-site/content/docs/framework/guides/observability-tracing.mdx +++ b/docs-site/content/docs/framework/guides/observability-tracing.mdx @@ -2,7 +2,7 @@ title: "可观测与链路追踪" --- -KsADK 提供本地 spans、Langfuse 兼容路径和标准 OTLP HTTP traces exporter。tracing 是可选诊断能力,不应成为 quickstart 的前置条件。 +KsADK 提供本地 spans 和标准 OTLP HTTP traces exporter。tracing 是可选诊断能力,不应成为 quickstart 的前置条件。托管 Agent 通过 CLI 或控制台创建时默认请求可观测性,由平台注入 Langfuse 主路和 CloudMonitor 次路;只有显式传入 `--no-observability` 或在控制台关闭才禁用。 从 `0.6.2` 开始,推荐把业务代码写成 **OTel-first**:应用只创建 OpenTelemetry spans、span events 和 attributes,后端通过 `OTEL_EXPORTER_OTLP_*` 环境变量路由到 Langfuse、OTel Collector 或其他兼容后端。 @@ -15,8 +15,8 @@ KsADK 提供本地 spans、Langfuse 兼容路径和标准 OTLP HTTP traces expor | 路径 | 默认行为 | 用途 | | --- | --- | --- | | In-memory spans | 本地 runner 路径默认启用 | 本地 debug API 和 Web UI trace 视图 | -| Generic OTLP HTTP | 设置 `OTEL_EXPORTER_OTLP_*` 后启用 | 发送到 Langfuse 或任意 OTLP HTTP Collector | -| Langfuse OTLP HTTP | 没有 generic OTLP 且设置 Langfuse key 时自动启用 | 兼容旧 Langfuse 环境变量 | +| Langfuse OTLP HTTP(主) | 本地设置 `OTEL_EXPORTER_OTLP_*` 后启用;托管环境默认由平台注入 | 发送到 Langfuse 或任意 OTLP HTTP backend | +| CloudMonitor OTLP HTTP(次) | 本地设置 `CLOUD_MONITOR_OTLP_*` 后启用;托管环境默认由平台注入 | 平台扩展,双写到云监控 trace 平台 | | OTLP gRPC | 通过 `enable_otlp=True` 显式启用 | 兼容旧代码路径 | | OpenInference instrumentation | best effort | 为 ADK、LangChain 等框架补充框架级 spans | @@ -48,18 +48,6 @@ export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer%20trace-token" headers 使用 OTLP 约定的逗号分隔格式,value 建议 URL encode。KsADK 会把 `Bearer%20trace-token` 解码为 `Bearer trace-token`。 -## Langfuse 兼容 - -如果团队仍使用 Langfuse 环境变量,原配置继续可用: - -```bash title="env.sh" -export LANGFUSE_BASE_URL="https://langfuse.example.com" - -agentengine run . -``` - -当 generic OTLP 和 Langfuse 环境变量同时存在时,`setup_tracing(enable_langfuse=None)` 优先使用 generic OTLP,不会再额外启用 Langfuse 直连 exporter。这样可以避免同一次运行在 Langfuse 中出现重复 traces。确实需要强制 Langfuse 兼容路径时,可显式调用 `setup_tracing(enable_langfuse=True)`。 - 如果把 Langfuse 当作 OTLP backend,也可以只配置标准 OTLP 变量: ```bash title="env.sh" @@ -72,67 +60,57 @@ export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Basic%20 -## Callback-only 模式 +## OTLP 双写 + +KsADK 收敛为每个 Python Agent 进程对应一个 TracerProvider + 两个 OTLP/HTTP BatchSpanProcessor:Langfuse 主(标准 `OTEL_EXPORTER_OTLP_*`)+ CloudMonitor 次(平台扩展 `CLOUD_MONITOR_OTLP_*`)。Langfuse CallbackHandler 路径已删除,不再通过 Langfuse SDK 上报。 -LangChain 或 LangGraph 项目有时更适合使用 Langfuse callback handler,而不是 direct OTLP。显式启用 callback-only: +两个 exporter 都运行在 Agent 进程内并直连各自 OTLP endpoint,不会部署 OpenTelemetry Collector、sidecar、额外容器或额外 Pod。 ```bash title="env.sh" -export LANGFUSE_BASE_URL="https://langfuse.example.com" -export LANGFUSE_USE_CALLBACK="true" +# 主路(Langfuse OTLP) +export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://langfuse.example.com/api/public/otel/v1/traces" +export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Basic%20,x-langfuse-ingestion-version=4" + +# 次路(CloudMonitor OTLP,平台注入) +export CLOUD_MONITOR_OTLP_TRACES_ENDPOINT="https://cloudmonitor.example.com/v1/traces" +export CLOUD_MONITOR_OTLP_HEADERS="Ksc-Appkey=" agentengine run . ``` - -同一次运行通常只选择 callback 或 direct OTLP 之一,除非已经确认重复 traces 可接受。 + +headers value 使用 RFC 3986 percent-encode(空格 `%20` 非 `+`)。KsADK 会解码为真实 header 值后传给 OTLP exporter。 -## 云监控双写 (0.6.7 新增) +## 云监控 OTLP - -`setup_tracing()` 自动探测 `CLOUD_MONITOR_*` 环境变量,与自建 Langfuse、标准 OTLP 互不压制。单条 span 可同时上报到自建可观测集群与云监控 trace 平台,无需在业务代码里区分后端。 - - -`setup_tracing()` 在初始化时检查 `CLOUD_MONITOR_*` 变量:存在 `CLOUD_MONITOR_OTLP_ENDPOINT` 或 `CLOUD_MONITOR_LANGFUSE_HOST` 时,分别构建云监控 OTLP exporter 和 Langfuse SDK callback,与自建 Langfuse / 标准 `OTEL_EXPORTER_OTLP_*` 路径并行写入。三条路径各自独立探测、互不压制,单条 span 可以同时进入自建集群和云监控 trace 平台。 +`setup_tracing()` 自动探测 `CLOUD_MONITOR_OTLP_*` 变量,构建 CloudMonitor OTLP exporter,与主路 Langfuse OTLP 并行写入。单条 span 同时进入两个后端,共享同一 `trace_id` / `span_id`。traces 专用 endpoint、protocol 和 headers 分别优先于通用值。 | 路径 | 鉴权 | 协议 | 触发条件 | | --- | --- | --- | --- | -| CloudMonitor OTLP exporter | `Ksc-Appkey` header | `http/protobuf` | `CLOUD_MONITOR_OTLP_ENDPOINT` 设置且 `CLOUD_MONITOR_OTLP_ENABLED` 未显式置 false | -| CloudMonitor Langfuse SDK CallbackHandler | 独立 TracerProvider,Langfuse public/secret key | Langfuse SDK callback | `LANGFUSE_USE_CALLBACK=true` 触发,或显式 `CLOUD_MONITOR_LANGFUSE_ENABLED=true` | +| CloudMonitor OTLP exporter | `Ksc-Appkey` header | `http/protobuf` | endpoint 已设置,且 traces 专用或通用 headers 提供 `Ksc-Appkey` | -CloudMonitor OTLP exporter 使用 `Ksc-Appkey` header 注入 AppKey 鉴权,固定 `http/protobuf` protocol;trace token 会归集回填到 root span,便于在云监控平台按一次完整调用聚合。CloudMonitor Langfuse SDK callback handler 挂在独立 TracerProvider 上,避免与业务侧 TracerProvider 互相干扰。 - -涉及的环境变量(共 11 个,参考 `../reference/environment-variables.md` 可观测小节): +涉及的环境变量: | 变量 | 敏感 | 说明 | | --- | --- | --- | -| `CLOUD_MONITOR_APP_KEY` | 是 | 云监控 OTLP AppKey,用于 `Ksc-Appkey` 鉴权。 | -| `CLOUD_MONITOR_OTLP_ENABLED` | 否 | 显式启用或禁用 CloudMonitor OTLP exporter,默认自动判断。 | -| `CLOUD_MONITOR_OTLP_ENDPOINT` | 否 | CloudMonitor 通用 OTLP HTTP endpoint,未设 traces 专用 endpoint 时派生 `/v1/traces`。 | -| `CLOUD_MONITOR_OTLP_PROTOCOL` | 否 | CloudMonitor 通用 OTLP 协议,当前支持 `http/protobuf`。 | -| `CLOUD_MONITOR_OTLP_HEADERS` | 是 | CloudMonitor OTLP 附加 headers,逗号分隔且 URL encoded。 | -| `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | 否 | CloudMonitor traces 专用 endpoint,优先于通用 endpoint。 | -| `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | 否 | CloudMonitor traces 专用协议,优先于通用 protocol。 | -| `CLOUD_MONITOR_LANGFUSE_ENABLED` | 否 | 显式启用或禁用 CloudMonitor Langfuse SDK callback,默认自动判断。 | -| `CLOUD_MONITOR_LANGFUSE_HOST` | 否 | CloudMonitor AppMonitor Langfuse SDK host,未设置时回落到 `CLOUD_MONITOR_OTLP_ENDPOINT`。 | -| `CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY` | 是 | CloudMonitor AppMonitor Langfuse public key。 | -| `CLOUD_MONITOR_LANGFUSE_SECRET_KEY` | 是 | CloudMonitor AppMonitor Langfuse secret key。 | +| `CLOUD_MONITOR_APP_KEY` | 是 | 已废弃,过渡兼容。仅当 `CLOUD_MONITOR_OTLP_TRACES_HEADERS` 和 `CLOUD_MONITOR_OTLP_HEADERS` 都整体缺失时翻译为 `Ksc-Appkey` header。 | +| `CLOUD_MONITOR_OTLP_ENDPOINT` | 否 | CloudMonitor 通用 OTLP HTTP endpoint,未设 traces 专用 endpoint 时派生 `/v1/traces`。 | +| `CLOUD_MONITOR_OTLP_PROTOCOL` | 否 | CloudMonitor 通用 OTLP 协议,当前支持 `http/protobuf`。 | +| `CLOUD_MONITOR_OTLP_HEADERS` | 是 | CloudMonitor OTLP 附加 headers,逗号分隔且 RFC 3986 encoded。 | +| `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | 否 | CloudMonitor traces 专用 endpoint,优先于通用 endpoint。 | +| `CLOUD_MONITOR_OTLP_TRACES_HEADERS` | 是 | CloudMonitor traces 专用 headers,优先于通用 headers;已提供但缺少 `Ksc-Appkey` 时 fail closed。 | +| `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | 否 | CloudMonitor traces 专用协议,优先于通用 protocol。 | -标注「是」的变量为平台 Secret 或可能携带鉴权信息,**不要**写入公开仓库的 `.env` 或镜像环境变量。使用占位值或平台密钥服务下发。 +标注「是」的变量为平台 Secret 或可能携带鉴权信息,**不要**写入公开仓库的 `.env` 或镜像环境变量。使用占位值或平台密钥服务下发。 -最小配置示例(占位值,请替换为自有 endpoint 与 AppKey): +最小配置示例(占位值,请替换为自有 endpoint 与 AppKey): ```bash title="env.sh" export CLOUD_MONITOR_OTLP_ENDPOINT="https://cloudmonitor.example.com" -export CLOUD_MONITOR_APP_KEY="cm-appkey-placeholder" - -# 可选:CloudMonitor Langfuse callback -export LANGFUSE_USE_CALLBACK="true" -export CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY="pk-cm" -export CLOUD_MONITOR_LANGFUSE_SECRET_KEY="cm-secret" -export CLOUD_MONITOR_LANGFUSE_HOST="https://cloudmonitor.example.com" +export CLOUD_MONITOR_OTLP_HEADERS="Ksc-Appkey=cm-appkey-placeholder" agentengine run . ``` @@ -194,12 +172,11 @@ span.set_attribute("score.comment", "Report covers evidence and next steps.") | 现象 | 可能原因 | 检查 | | --- | --- | --- | | 本地 trace 视图为空 | 没有运行产生 spans,或 tracing 被禁用 | 先跑一次请求,再确认本地服务启用了 tracing | -| generic OTLP 没有数据 | endpoint、TLS、auth 或 Collector policy 不匹配 | 检查 `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` 和 headers | -| Langfuse 没有收到 spans | key 缺失、base URL 错误、callback-only 或 generic OTLP 抢占 | 检查 Langfuse 变量、`LANGFUSE_USE_CALLBACK` 和 `OTEL_EXPORTER_OTLP_*` | -| Langfuse 出现重复 traces | callback 和 direct OTLP 同时启用 | 同一项目选择一种路径 | +| Langfuse 没有收到 spans | endpoint、headers、TLS 或 auth 不匹配 | 检查 `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` 和 `OTEL_EXPORTER_OTLP_TRACES_HEADERS`(value 需 RFC 3986 encode) | +| 云监控看不到 spans | 选中的 traces/通用 headers 缺 `Ksc-Appkey`,或 endpoint 未派生 `/v1/traces` | 优先检查 `CLOUD_MONITOR_OTLP_TRACES_HEADERS`,再检查通用 headers 和 endpoint;旧 AppKey 只在两个 headers 变量都未设置时 fallback | +| 双写一边缺失 | 单后端故障 | 主路故障时次路仍可用,反之亦然;Agent 业务不阻断 | | score 没有显示为 native score | 后端没有把 `score.*` attributes 做映射 | 在 Collector 或平台服务侧增加转换逻辑 | | 框架 spans 较少 | optional instrumentation 未安装或未生效 | 安装 tracing extra,并检查框架 instrumentation | -| 云监控看不到 spans | `CLOUD_MONITOR_APP_KEY`/`CLOUD_MONITOR_OTLP_ENDPOINT` 缺失,或 protocol 非 `http/protobuf` | 检查 `CLOUD_MONITOR_OTLP_ENABLED` 未显式置 `false`、endpoint 已派生 `/v1/traces`、`Ksc-Appkey` header 已注入 | ## 公开文档规则 diff --git a/docs-site/content/docs/framework/guides/web-ui-source.en.mdx b/docs-site/content/docs/framework/guides/web-ui-source.en.mdx index 7abcf742..abbda85a 100644 --- a/docs-site/content/docs/framework/guides/web-ui-source.en.mdx +++ b/docs-site/content/docs/framework/guides/web-ui-source.en.mdx @@ -4,14 +4,16 @@ title: "Web UI Repository" The KsADK Web UI source lives in the independent `kingsoftcloud/ksadk-web` repository and is published as the npm package `@kingsoftcloud/ksadk-web`. -`ksadk-python` no longer builds the UI source locally; instead it synchronizes -the `dist-ksadk` output from the npm package into `ksadk/server/static` via -`make sync-ksadk-web-static`, for the local `agentengine web` server. +`ksadk-python` synchronizes the `dist-ksadk` output from the npm package into +`ksadk/server/static` for the local `agentengine web` server. Its own Studio +source is built from the tracked `ksadk/studio/react-ui` project into the +ignored `ksadk/studio/static` payload during a controlled package build. Since 0.6.5 `ksadk-python` removed the local `npm ci` / `npm run build:ksadk` build chain and no longer pulls GitHub latest release at wheel build time. -All static assets come from the `@kingsoftcloud/ksadk-web` npm package. +The hosted UI payload comes from the `@kingsoftcloud/ksadk-web` npm package; +the separately tracked Studio source is built from its checked-in lockfile. @@ -21,13 +23,13 @@ All static assets come from the `@kingsoftcloud/ksadk-web` npm package. | --- | --- | | `kingsoftcloud/ksadk-web` | Editable React/Vite source, tests, Pages demo, npm publishing | | `@kingsoftcloud/ksadk-web` (npm) | Distribution channel for `dist-ksadk` / `dist-hosted` build outputs | -| `ksadk-python` | Python SDK, CLI, runner, local server, embedded `ksadk/server/static` | +| `ksadk-python` | Python SDK, CLI, runner, local server, Studio source, embedded `server/static` and `studio/static` | | hosted UI | Production deployment shell, gateway, image and environment injection; also consumes the npm package | The `ksadk-python` public clean export does not contain `ksadk/server/web-ui` source, nor `node_modules`, `dist/`, or `dist-hosted/` intermediate UI artifacts. -The editable UI source is maintained only in the `kingsoftcloud/ksadk-web` -repository. +The hosted editable UI source is maintained in `kingsoftcloud/ksadk-web`; the +Studio source is tracked locally and its compiled output is ignored. ## Sync Rules @@ -38,8 +40,8 @@ repository. # Default: pull the verified npm version make sync-ksadk-web-static -# Pin the concrete 0.8.0 release-candidate version -make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0 +# Pin the concrete 0.8.1 release-candidate version +make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.1 ``` @@ -51,25 +53,26 @@ audited tarball. -### make build / build-wheel auto-sync +### Controlled package build -`make build` and `make build-wheel` run `sync-ksadk-web-static` before building -the wheel, so the embedded static assets always come from the controlled npm -version: +`make build` and `make build-wheel` synchronize `ksadk-web` and build the +locked React Studio source before building the wheel, so the embedded static +assets always come from controlled inputs: ```bash # 1. sync-ksadk-web-static -> ksadk/server/static -# 2. python -m build / uv build +# 2. npm ci && npm run build -> ksadk/studio/static +# 3. python -m build / uv build ``` -If the static assets are already synced and you want to skip sync, use -`make build-only` (it verifies that `ksadk/server/static/index.html` exists). +`make build-only` still requires a current Studio build, while reusing an +already synchronized `ksadk/server/static` payload. ### Sync Variables | Variable | Default | Description | | --- | --- | --- | -| `KSADK_WEB_VERSION` | `0.3.0` | published npm package version; release candidates must pin a concrete version | +| `KSADK_WEB_VERSION` | `0.3.1` | published npm package version; release candidates must pin a concrete version | | `KSADK_WEB_PACKAGE` | `@kingsoftcloud/ksadk-web` | npm package name | | `KSADK_WEB_TARBALL_NAME` | `kingsoftcloud-ksadk-web-.tgz` | Tarball filename, derived from the version | | `KSADK_WEB_RELEASE_URL` | empty | Explicit tarball URL fallback; takes precedence over `npm pack` | @@ -84,14 +87,14 @@ to exist before it is copied over `ksadk/server/static`. Each `ksadk-python` release note should record: -- The `ksadk-python` version (e.g. `0.8.0`). -- The `KSADK_WEB_VERSION` / npm package version (e.g. `0.8.0` maps to `@kingsoftcloud/ksadk-web@0.3.0`). -- The sync command (e.g. `make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0`). +- The `ksadk-python` version (e.g. `0.8.1`). +- The `KSADK_WEB_VERSION` / npm package version (e.g. `0.8.1` maps to `@kingsoftcloud/ksadk-web@0.3.1`). +- The controlled build command (e.g. `make build-frontend KSADK_WEB_VERSION=0.3.1`). - The wheel / sdist audit result (`make public-build-check` / `twine check dist/*`). - -- `ksadk-python`: `0.8.0` -- npm package: `@kingsoftcloud/ksadk-web@0.3.0` -- sync: `make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0` + +- `ksadk-python`: `0.8.1` +- npm package: `@kingsoftcloud/ksadk-web@0.3.1` +- frontend build: `make build-frontend KSADK_WEB_VERSION=0.3.1` - audit: `make public-build-check` passed, `twine check dist/*` passed diff --git a/docs-site/content/docs/framework/guides/web-ui-source.mdx b/docs-site/content/docs/framework/guides/web-ui-source.mdx index 1ae097f6..10c364ab 100644 --- a/docs-site/content/docs/framework/guides/web-ui-source.mdx +++ b/docs-site/content/docs/framework/guides/web-ui-source.mdx @@ -3,14 +3,15 @@ title: "Web UI 仓库" --- KsADK Web UI 源码属于独立仓库 `kingsoftcloud/ksadk-web`,并以 npm 包 -`@kingsoftcloud/ksadk-web` 发布。`ksadk-python` 不再本地构建 UI 源码,统一通过 -`make sync-ksadk-web-static` 从 npm 包的 `dist-ksadk` 目录同步静态产物到 -`ksadk/server/static`,供本地 `agentengine web` 使用。 +`@kingsoftcloud/ksadk-web` 发布。`ksadk-python` 通过 `make sync-ksadk-web-static` +从 npm 包的 `dist-ksadk` 目录同步静态产物到 `ksadk/server/static`,供本地 +`agentengine web` 使用;本仓库自己的 Studio 源码则从受跟踪的 +`ksadk/studio/react-ui` 构建到被 Git 忽略的 `ksadk/studio/static`。 自 0.6.5 起 `ksadk-python` 移除了本地 `npm ci` / `npm run build:ksadk` 构建链路, -不再在 wheel 构建时实时拉取 GitHub latest release。所有静态产物来自 npm 包 -`@kingsoftcloud/ksadk-web`。 +不再在 wheel 构建时实时拉取 GitHub latest release。Hosted UI 产物来自 npm 包 +`@kingsoftcloud/ksadk-web`;受跟踪的 Studio 源码则依据自身 lockfile 构建。 @@ -20,12 +21,12 @@ KsADK Web UI 源码属于独立仓库 `kingsoftcloud/ksadk-web`,并以 npm 包 | --- | --- | | `kingsoftcloud/ksadk-web` | 可编辑 React/Vite 源码、测试、Pages demo、npm 发布 | | `@kingsoftcloud/ksadk-web` (npm) | 构建产物 `dist-ksadk` / `dist-hosted` 的分发渠道 | -| `ksadk-python` | Python SDK、CLI、runner、本地 server、嵌入式 `ksadk/server/static` | +| `ksadk-python` | Python SDK、CLI、runner、本地 server、Studio 源码、嵌入式 `server/static` 与 `studio/static` | | hosted UI | 生产部署壳、网关、镜像和环境注入,同样从 npm 包消费 | `ksadk-python` 的公开 clean export 不包含 `ksadk/server/web-ui` 源码,也不包含 -`node_modules`、`dist/`、`dist-hosted/` 等 UI 仓库中间产物。可编辑 UI 源码只在 -`kingsoftcloud/ksadk-web` 仓库维护。 +`node_modules`、`dist/`、`dist-hosted/` 等 UI 仓库中间产物。Hosted Web UI 源码在 +`kingsoftcloud/ksadk-web` 仓库维护;Studio 源码则在本仓库受跟踪,其编译产物被忽略。 ## 同步规则 @@ -36,8 +37,8 @@ KsADK Web UI 源码属于独立仓库 `kingsoftcloud/ksadk-web`,并以 npm 包 # 默认拉已验证的 npm 版本 make sync-ksadk-web-static -# 0.8.0 发布候选固定具体版本 -make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0 +# 0.8.1 发布候选固定具体版本 +make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.1 ``` @@ -47,24 +48,25 @@ make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0 -### make build / build-wheel 自动 sync +### 受控包构建 -`make build` 与 `make build-wheel` 会先执行 `sync-ksadk-web-static`,再构建 wheel, -保证 wheel 中的静态产物来自受控的 npm 版本: +`make build` 与 `make build-wheel` 会先同步 `ksadk-web` 并构建锁定的 React Studio +源码,再构建 wheel,保证 wheel 中的静态产物来自受控输入: ```bash # 1. sync-ksadk-web-static → ksadk/server/static -# 2. python -m build / uv build +# 2. npm ci && npm run build → ksadk/studio/static +# 3. python -m build / uv build ``` -若已同步好静态产物、想跳过 sync,可使用 `make build-only`(会校验 -`ksadk/server/static/index.html` 存在)。 +`make build-only` 可复用已同步的 `ksadk/server/static`,但仍会构建当前 Studio +静态产物。 ### 同步变量 | 变量 | 默认值 | 说明 | | --- | --- | --- | -| `KSADK_WEB_VERSION` | `0.3.0` | 已发布的 npm 包版本;发布候选必须固定为具体版本 | +| `KSADK_WEB_VERSION` | `0.3.1` | 已发布的 npm 包版本;发布候选必须固定为具体版本 | | `KSADK_WEB_PACKAGE` | `@kingsoftcloud/ksadk-web` | npm 包名 | | `KSADK_WEB_TARBALL_NAME` | `kingsoftcloud-ksadk-web-.tgz` | tarball 文件名,由版本推导 | | `KSADK_WEB_RELEASE_URL` | 空 | 显式指定 tarball URL 兜底,优先级高于 npm pack | @@ -78,14 +80,14 @@ sync 优先级:`KSADK_WEB_RELEASE_URL` 显式 tarball > `npm pack` > registry 每次 `ksadk-python` release note 应记录: -- `ksadk-python` 版本(如 `0.8.0`)。 -- `KSADK_WEB_VERSION` / npm 包版本(如 `0.8.0` 对应 `@kingsoftcloud/ksadk-web@0.3.0`)。 -- sync 命令(如 `make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0`)。 +- `ksadk-python` 版本(如 `0.8.1`)。 +- `KSADK_WEB_VERSION` / npm 包版本(如 `0.8.1` 对应 `@kingsoftcloud/ksadk-web@0.3.1`)。 +- 受控构建命令(如 `make build-frontend KSADK_WEB_VERSION=0.3.1`)。 - wheel / sdist 审计结果(`make public-build-check` / `twine check dist/*`)。 - -- `ksadk-python`: `0.8.0` -- npm 包: `@kingsoftcloud/ksadk-web@0.3.0` -- sync: `make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0` + +- `ksadk-python`: `0.8.1` +- npm 包: `@kingsoftcloud/ksadk-web@0.3.1` +- 前端构建: `make build-frontend KSADK_WEB_VERSION=0.3.1` - 审计: `make public-build-check` 通过,`twine check dist/*` 通过 diff --git a/docs-site/content/docs/framework/index.en.mdx b/docs-site/content/docs/framework/index.en.mdx index 65819d78..3b740860 100644 --- a/docs-site/content/docs/framework/index.en.mdx +++ b/docs-site/content/docs/framework/index.en.mdx @@ -30,6 +30,7 @@ pip install -U "ksadk[all]" ## New in 0.8 and advanced topics + diff --git a/docs-site/content/docs/framework/index.mdx b/docs-site/content/docs/framework/index.mdx index fedf9e1e..57fc317e 100644 --- a/docs-site/content/docs/framework/index.mdx +++ b/docs-site/content/docs/framework/index.mdx @@ -30,6 +30,7 @@ pip install -U "ksadk[all]" ## 0.8 新能力与进阶主题 + diff --git a/docs-site/content/docs/framework/meta.json b/docs-site/content/docs/framework/meta.json index a5515320..80973c51 100644 --- a/docs-site/content/docs/framework/meta.json +++ b/docs-site/content/docs/framework/meta.json @@ -17,6 +17,7 @@ "tutorials", "guides/harness-app", "guides/local-web-ui", + "guides/agentkit-local-studio", "guides/hosted-ui-events", "---[Blocks]运行时能力---", "guides/agent-context", diff --git a/docs-site/content/docs/references/environment-variables.en.mdx b/docs-site/content/docs/references/environment-variables.en.mdx index 0310535f..c5719397 100644 --- a/docs-site/content/docs/references/environment-variables.en.mdx +++ b/docs-site/content/docs/references/environment-variables.en.mdx @@ -41,6 +41,10 @@ OPENAI_MODEL_NAME=my-model | Variable | Purpose | | --- | --- | +| `KSADK_CODEX_APPROVAL` | Codex approval policy; Studio can select it for the next turn | +| `KSADK_CODEX_HOME` | explicit native Codex HOME override; normally isolated automatically | +| `KSADK_CODEX_ISOLATE_HOME` | defaults to `1`; set to `0` only when debugging with the process HOME | +| `KSADK_CODEX_SANDBOX` | Codex sandbox mode: `read_only`, `workspace_write`, or `full_access` | | `KSADK_CODEX_USE_PROXY` | `1` forces the local Codex protocol proxy and `0` forces direct mode; unset performs a conservative probe for custom upstreams only | | `KSADK_PROXY_UPSTREAM_BASE` | Codex proxy upstream URL override; defaults to the OpenAI-compatible base URL | | `KSADK_PROXY_UPSTREAM_KEY` | Codex proxy upstream credential override (sensitive) | @@ -49,6 +53,9 @@ OPENAI_MODEL_NAME=my-model | `KSADK_MODEL_PROXY_AGENTS` | comma-separated agent allowlist | | `KSADK_MODEL_PROXY_MODELS` | comma-separated model allowlist | | `KSADK_MODEL_PROXY_DENY` | comma-separated denylist for an emergency proxy disable | +| `KSADK_STUDIO_NO_SECURITY` | controlled tests only; `1` disables the local session and CSRF checks | +| `KSADK_STUDIO_SESSION_TOKEN` | local Studio browser session token (sensitive; generated randomly when unset) | +| `KSADK_STUDIO_TRACE_CONTENT` | persist trace event content, default `1`; set to `0` to retain metadata only | ## Runtime, A2A, and A2UI in 0.8 @@ -312,7 +319,7 @@ KSADK_MCP_SERVERS='[{"name":"docs","url":"http://127.0.0.1:9000/mcp"}]' | Variable | Purpose | | --- | --- | -| `KSADK_WEB_VERSION` | published `@kingsoftcloud/ksadk-web` npm version used by `make sync-ksadk-web-static`; the 0.8.0 default is `0.3.0`. Publish and verify a new version before using it in a wheel build. | +| `KSADK_WEB_VERSION` | published `@kingsoftcloud/ksadk-web` npm version used by `make sync-ksadk-web-static`; the 0.8.x default is `0.3.1`. Publish and verify a new version before using it in a wheel build. | | `KSADK_WEB_PACKAGE` | npm package name used for local UI static sync; default `@kingsoftcloud/ksadk-web` | | `KSADK_WEB_TARBALL_NAME` | saved filename when `KSADK_WEB_RELEASE_URL` is set; npm pack mode uses the real tarball filename returned by npm | | `KSADK_WEB_RELEASE_URL` | optional fallback; when set, skips npm pack and downloads from this tarball URL | @@ -335,36 +342,29 @@ KSADK_MCP_SERVERS='[{"name":"docs","url":"http://127.0.0.1:9000/mcp"}]' | `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | traces-specific OTLP protocol; takes precedence over the generic protocol | | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | traces-specific OTLP HTTP headers; takes precedence over generic headers and may contain auth data | | `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | maximum spans exported per OTLP batch, default `64`, to avoid oversized collector requests | -| `LANGFUSE_PUBLIC_KEY` | compatibility path for older Langfuse tracing auto-configuration | -| `LANGFUSE_SECRET_KEY` | Langfuse secret | -| `LANGFUSE_BASE_URL` | Langfuse base URL | -| `LANGFUSE_HOST` | compatibility alias for Langfuse host | -| `LANGFUSE_USE_CALLBACK` | use framework callback mode instead of direct OTLP path | | `SESSION_TITLE_MODEL` | model override for generated local session titles | | `COMPACTION_DISABLE_SEMANTIC` | disable semantic session summary compaction | | `COMPACTION_SUMMARY_TIMEOUT_MS` | timeout for summary generation | | `COMPACTION_SUMMARY_MAX_GROUPS` | maximum message groups summarized | | `COMPACTION_SUMMARY_MODEL` | model override for semantic summaries | -### CloudMonitor Dual-Write (CLOUD_MONITOR_*) +### CloudMonitor OTLP Dual-Write (CLOUD_MONITOR_*) -From 0.6.7, `setup_tracing()` auto-detects `CLOUD_MONITOR_*` environment variables and mounts the CloudMonitor OTLP exporter and the CloudMonitor Langfuse SDK CallbackHandler in parallel; neither suppresses self-hosted Langfuse or standard OTLP, and a single span can be reported to both the self-hosted cluster and the CloudMonitor trace platform at once. +`setup_tracing()` auto-detects `CLOUD_MONITOR_OTLP_*` variables and builds a CloudMonitor OTLP exporter writing in parallel with the primary Langfuse OTLP (standard `OTEL_EXPORTER_OTLP_*`). A single span enters both backends with the same `trace_id` and `span_id`. Managed Agents created from either the CLI or console receive both routes by default; use `--no-observability`, or turn observability off in the console, to disable them explicitly. The Langfuse CallbackHandler path has been removed. | Variable | Purpose | | --- | --- | -| `CLOUD_MONITOR_APP_KEY` | CloudMonitor OTLP AppKey; injected as the `Ksc-Appkey` auth header (sensitive) | -| `CLOUD_MONITOR_OTLP_ENABLED` | explicitly enable or disable the CloudMonitor OTLP exporter; auto-detected by default | +| `CLOUD_MONITOR_APP_KEY` | **Deprecated** transition fallback; used as `Ksc-Appkey` only when both `CLOUD_MONITOR_OTLP_TRACES_HEADERS` and `CLOUD_MONITOR_OTLP_HEADERS` are entirely absent (sensitive) | | `CLOUD_MONITOR_OTLP_ENDPOINT` | CloudMonitor generic OTLP HTTP endpoint; derives `/v1/traces` when no traces endpoint is set | | `CLOUD_MONITOR_OTLP_PROTOCOL` | CloudMonitor generic OTLP protocol; currently supports `http/protobuf` | -| `CLOUD_MONITOR_OTLP_HEADERS` | CloudMonitor OTLP extra headers, comma-separated and URL-encoded (sensitive) | +| `CLOUD_MONITOR_OTLP_HEADERS` | CloudMonitor OTLP extra headers, comma-separated and RFC 3986 encoded (sensitive) | | `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | CloudMonitor traces-specific endpoint; takes precedence over the generic endpoint | +| `CLOUD_MONITOR_OTLP_TRACES_HEADERS` | CloudMonitor traces-specific headers; take precedence over generic headers and fail closed when present without `Ksc-Appkey` (sensitive) | | `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | CloudMonitor traces-specific protocol; takes precedence over the generic protocol | -| `CLOUD_MONITOR_LANGFUSE_ENABLED` | explicitly enable or disable the CloudMonitor Langfuse SDK callback; auto-detected by default | -| `CLOUD_MONITOR_LANGFUSE_HOST` | CloudMonitor AppMonitor Langfuse SDK host; falls back to `CLOUD_MONITOR_OTLP_ENDPOINT` | -| `CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY` | CloudMonitor Langfuse public key (sensitive) | -| `CLOUD_MONITOR_LANGFUSE_SECRET_KEY` | CloudMonitor Langfuse secret key (sensitive) | -Set `CLOUD_MONITOR_OTLP_ENABLED=false` or `CLOUD_MONITOR_LANGFUSE_ENABLED=false` to disable the corresponding path. + +When one backend fails the other still works: primary failure keeps the secondary, secondary failure keeps the primary, Agent business is not blocked. + ## Safety Rules diff --git a/docs-site/content/docs/references/environment-variables.mdx b/docs-site/content/docs/references/environment-variables.mdx index d6938626..66527da9 100644 --- a/docs-site/content/docs/references/environment-variables.mdx +++ b/docs-site/content/docs/references/environment-variables.mdx @@ -41,6 +41,10 @@ OPENAI_MODEL_NAME=my-model | 变量 | 用途 | | --- | --- | +| `KSADK_CODEX_APPROVAL` | Codex 审批策略;Studio 可按下一轮对话传入审批等级 | +| `KSADK_CODEX_HOME` | 原生 Codex HOME 显式覆盖;通常由运行时自动隔离 | +| `KSADK_CODEX_ISOLATE_HOME` | 默认 `1`;设为 `0` 仅用于调试复用进程 HOME | +| `KSADK_CODEX_SANDBOX` | Codex 沙箱模式:`read_only`、`workspace_write` 或 `full_access` | | `KSADK_CODEX_USE_PROXY` | `1` 强制启用 Codex 本地协议代理,`0` 强制直连;未设置时只对自定义上游做保守探测 | | `KSADK_PROXY_UPSTREAM_BASE` | Codex proxy 上游 URL 覆盖,默认读取 OpenAI 兼容 base URL | | `KSADK_PROXY_UPSTREAM_KEY` | Codex proxy 上游凭据覆盖(敏感) | @@ -49,6 +53,9 @@ OPENAI_MODEL_NAME=my-model | `KSADK_MODEL_PROXY_AGENTS` | 逗号分隔的 agent allowlist | | `KSADK_MODEL_PROXY_MODELS` | 逗号分隔的 model allowlist | | `KSADK_MODEL_PROXY_DENY` | 逗号分隔的 denylist,用于紧急关闭代理 | +| `KSADK_STUDIO_NO_SECURITY` | 仅受控自动化测试使用;设为 `1` 会关闭本地 session/CSRF 校验 | +| `KSADK_STUDIO_SESSION_TOKEN` | Studio 本地浏览器 session token(敏感;未设置时随机生成) | +| `KSADK_STUDIO_TRACE_CONTENT` | 是否保存 Trace 事件正文,默认 `1`;设为 `0` 只保留元数据 | ## 0.8 Runtime、A2A 与 A2UI @@ -309,7 +316,7 @@ KSADK_MCP_SERVERS='[{"name":"docs","url":"http://127.0.0.1:9000/mcp"}]' | 变量 | 用途 | | --- | --- | -| `KSADK_WEB_VERSION` | `make sync-ksadk-web-static` 使用的已发布 `@kingsoftcloud/ksadk-web` npm 版本,0.8.0 默认 `0.3.0`;wheel 构建前必须先发布并验证新版本 | +| `KSADK_WEB_VERSION` | `make sync-ksadk-web-static` 使用的已发布 `@kingsoftcloud/ksadk-web` npm 版本,0.8.x 默认 `0.3.1`;wheel 构建前必须先发布并验证新版本 | | `KSADK_WEB_PACKAGE` | 本地 UI static 同步使用的 npm 包名,默认 `@kingsoftcloud/ksadk-web` | | `KSADK_WEB_TARBALL_NAME` | 设置 `KSADK_WEB_RELEASE_URL` 时作为下载保存文件名;npm pack 模式使用 npm 返回的真实 tarball 文件名 | | `KSADK_WEB_RELEASE_URL` | 可选兜底;设置后跳过 npm pack,改从该 tarball URL 下载 | @@ -332,36 +339,29 @@ KSADK_MCP_SERVERS='[{"name":"docs","url":"http://127.0.0.1:9000/mcp"}]' | `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | traces 专用 OTLP protocol,优先于通用 protocol | | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | traces 专用 OTLP HTTP headers,优先于通用 headers,可能包含鉴权信息 | | `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | OTLP 单次 export 最大 span 数,默认 `64`,用于避免 collector 请求过大 | -| `LANGFUSE_PUBLIC_KEY` | 兼容旧 Langfuse tracing 自动配置 | -| `LANGFUSE_SECRET_KEY` | Langfuse secret | -| `LANGFUSE_BASE_URL` | Langfuse base URL | -| `LANGFUSE_HOST` | Langfuse host 兼容别名 | -| `LANGFUSE_USE_CALLBACK` | 使用框架 callback 模式,而不是直接 OTLP 路径 | | `SESSION_TITLE_MODEL` | 本地会话标题生成模型覆盖 | | `COMPACTION_DISABLE_SEMANTIC` | 禁用语义摘要压缩 | | `COMPACTION_SUMMARY_TIMEOUT_MS` | 摘要生成超时时间 | | `COMPACTION_SUMMARY_MAX_GROUPS` | 摘要消息分组上限 | | `COMPACTION_SUMMARY_MODEL` | 语义摘要模型覆盖 | -### 云监控双写(CLOUD_MONITOR_*) +### 云监控 OTLP 双写(CLOUD_MONITOR_OTLP_*) -0.6.7 起 `setup_tracing()` 自动探测 `CLOUD_MONITOR_*` 环境变量,并行挂载 CloudMonitor OTLP exporter 和 CloudMonitor Langfuse SDK CallbackHandler,与自建 Langfuse / 标准 OTLP 互不压制,单条 span 可同时上报自建集群与云监控 trace 平台。 +`setup_tracing()` 自动探测 `CLOUD_MONITOR_OTLP_*` 环境变量,构建 CloudMonitor OTLP exporter,与主路 Langfuse OTLP(标准 `OTEL_EXPORTER_OTLP_*`)并行写入。单条 span 同时进入两个后端,共享同一 `trace_id` / `span_id`。托管 Agent 通过 CLI 或控制台创建时默认由平台注入双路配置;只有显式 `--no-observability` 或在控制台关闭才禁用。Langfuse CallbackHandler 路径已删除。 | 变量 | 用途 | | --- | --- | -| `CLOUD_MONITOR_APP_KEY` | 云监控 OTLP AppKey,注入 `Ksc-Appkey` 鉴权 header(敏感) | -| `CLOUD_MONITOR_OTLP_ENABLED` | 显式启用或禁用 CloudMonitor OTLP exporter,默认自动判断 | +| `CLOUD_MONITOR_APP_KEY` | 已废弃,过渡兼容。仅当 `CLOUD_MONITOR_OTLP_TRACES_HEADERS` 和 `CLOUD_MONITOR_OTLP_HEADERS` 都整体缺失时翻译为 `Ksc-Appkey` header(敏感) | | `CLOUD_MONITOR_OTLP_ENDPOINT` | CloudMonitor 通用 OTLP HTTP endpoint;未设 traces endpoint 时派生 `/v1/traces` | | `CLOUD_MONITOR_OTLP_PROTOCOL` | CloudMonitor 通用 OTLP 协议,当前支持 `http/protobuf` | -| `CLOUD_MONITOR_OTLP_HEADERS` | CloudMonitor OTLP 附加 headers,逗号分隔且 URL encoded(敏感) | +| `CLOUD_MONITOR_OTLP_HEADERS` | CloudMonitor OTLP 附加 headers,逗号分隔且 RFC 3986 encoded(敏感) | | `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | CloudMonitor traces 专用 endpoint,优先于通用 endpoint | +| `CLOUD_MONITOR_OTLP_TRACES_HEADERS` | CloudMonitor traces 专用 headers,优先于通用 headers;已提供但缺 `Ksc-Appkey` 时 fail closed(敏感) | | `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | CloudMonitor traces 专用协议,优先于通用 protocol | -| `CLOUD_MONITOR_LANGFUSE_ENABLED` | 显式启用或禁用 CloudMonitor Langfuse SDK callback,默认自动判断 | -| `CLOUD_MONITOR_LANGFUSE_HOST` | CloudMonitor AppMonitor Langfuse SDK host,回退 `CLOUD_MONITOR_OTLP_ENDPOINT` | -| `CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY` | CloudMonitor Langfuse public key(敏感) | -| `CLOUD_MONITOR_LANGFUSE_SECRET_KEY` | CloudMonitor Langfuse secret key(敏感) | -显式设置 `CLOUD_MONITOR_OTLP_ENABLED=false` 或 `CLOUD_MONITOR_LANGFUSE_ENABLED=false` 可分别禁用对应路径。 + +单后端故障时另一路仍可用:主路故障次路仍写,次路故障主路仍写,Agent 业务不阻断。 + ## 安全规则 diff --git "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" index f93b811b..b7f2e2b9 100644 --- "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" +++ "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" @@ -101,8 +101,9 @@ | 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 | | --- | --- | --- | --- | --- | --- | | `KSADK_KB_DATASET_ID` | 条件必传 | 无 | 否 | 平台 / 开发者 | 配置后启用知识库检索。 | -| `KSADK_KB_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY` | 是 | 平台 Secret | SDK 知识库 backend AK。 | -| `KSADK_KB_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY` | 是 | 平台 Secret | SDK 知识库 backend SK。 | +| `KSADK_KB_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY`、`KSYUN_ACCESS_KEY_ID` | 是 | 平台 Secret | SDK 知识库 backend AK。 | +| `KSADK_KB_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY`、`KSYUN_SECRET_ACCESS_KEY` | 是 | 平台 Secret | SDK 知识库 backend SK。 | +| `KSADK_KB_SESSION_TOKEN` | 否 | `KSYUN_SESSION_TOKEN` | 是 | 平台 Secret | SDK 知识库 backend 的 STS 临时会话 token。 | | `KSADK_KB_ENDPOINT` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `aicp.api.ksyun.com`。 | | `KSADK_KB_REGION` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `cn-beijing-6`。 | | `KSADK_KB_SCHEME` | 否 | 无 | 否 | 平台 / 开发者 | KB endpoint 协议。内网 endpoint 默认 `http`,其他默认 `https`。 | @@ -110,8 +111,9 @@ | `KSADK_LTM_BACKEND` | 否 | 无 | 否 | 开发者 | 长期记忆 backend,默认 `local`,可选 `http/sdk`。 | | `KSADK_LTM_HTTP_URL` | 条件必传 | 无 | 是 | 平台 Secret | `KSADK_LTM_BACKEND=http` 时需要。 | | `KSADK_LTM_HTTP_TOKEN` | 条件必传 | 无 | 是 | 平台 Secret | HTTP LTM 鉴权 token。 | -| `KSADK_LTM_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY` | 是 | 平台 Secret | SDK LTM AK。 | -| `KSADK_LTM_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY` | 是 | 平台 Secret | SDK LTM SK。 | +| `KSADK_LTM_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY`、`KSYUN_ACCESS_KEY_ID` | 是 | 平台 Secret | SDK LTM AK。 | +| `KSADK_LTM_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY`、`KSYUN_SECRET_ACCESS_KEY` | 是 | 平台 Secret | SDK LTM SK。 | +| `KSADK_LTM_SESSION_TOKEN` | 否 | `KSYUN_SESSION_TOKEN` | 是 | 平台 Secret | SDK LTM backend 的 STS 临时会话 token。 | | `KSADK_LTM_AMBIENT_POLICY` | 否 | 无 | 否 | 平台 / 开发者 | runtime 自动注入长期记忆上下文策略:`on_demand/always/disabled`。 | | `KSADK_MEMORY_BACKEND` | 否 | 无 | 否 | 开发者 | 轻量 KV/消息历史 MemoryManager backend,默认 `memory`。 | | `KSADK_MEMORY_URL` | 条件必传 | 无 | 是 | 开发者 / Secret | `KSADK_MEMORY_BACKEND=redis` 等远端 backend 连接 URL。 | @@ -120,17 +122,18 @@ | `KSADK_SESSION_PATH` | 否 | `KSADK_STM_PATH`、`KSADK_STM_DB_PATH` | 否 | 本地运行时 | 本地 SQLite 会话库路径。 | | `KSADK_SESSION_NAMESPACE` | 否 | `KSADK_WORKSPACE_ID`、`AGENTENGINE_WORKSPACE_ID`、`KSADK_TENANT_ID`、`AGENTENGINE_TENANT_ID` | 否 | 平台 / 开发者 | 会话命名空间。 | -### 2.8 可观测性和 Langfuse +### 2.8 可观测性和 OTLP | 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 | | --- | --- | --- | --- | --- | --- | -| `LANGFUSE_PUBLIC_KEY` | 条件必传 | 无 | 是 | 平台 Secret / 开发者 | 启用 Langfuse 时需要。 | -| `LANGFUSE_SECRET_KEY` | 条件必传 | 无 | 是 | 平台 Secret / 开发者 | 启用 Langfuse 时需要。 | -| `LANGFUSE_BASE_URL` | 否 | `LANGFUSE_HOST` | 否 | 平台 / 开发者 | Langfuse endpoint。 | -| `LANGFUSE_USE_CALLBACK` | 否 | 无 | 否 | 开发者 | 控制是否启用 callback 集成。 | -| `CLOUD_MONITOR_APP_KEY` | 条件必传 | 无 | 是 | 平台 Secret | 云监控 OTLP AppKey。 | +| `CLOUD_MONITOR_APP_KEY` | 否 | 一个版本的过渡 fallback | 是 | 旧平台 Secret | 已废弃;仅当 traces 与通用 headers 变量都整体缺失时翻译为 `Ksc-Appkey`。 | | `CLOUD_MONITOR_OTLP_ENDPOINT` | 条件必传 | 无 | 否 | 平台 / 开发者 | CloudMonitor 通用 OTLP HTTP endpoint。 | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | 条件必传 | 无 | 否 | 平台 / 开发者 | OTel Collector endpoint;未设置 traces 专用 endpoint 时,KsADK 会派生 `/v1/traces`。 | +| `CLOUD_MONITOR_OTLP_PROTOCOL` | 否 | 无 | 否 | 平台 / 开发者 | CloudMonitor 通用协议,当前支持 `http/protobuf`。 | +| `CLOUD_MONITOR_OTLP_HEADERS` | 条件必传(与 traces headers 二选一) | 旧 AppKey fallback | 是 | 平台 | CloudMonitor 通用 headers,必须包含 `Ksc-Appkey`。 | +| `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | 否 | 无 | 否 | 平台 / 开发者 | traces 专用 endpoint,优先于通用 endpoint。 | +| `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | 否 | 无 | 否 | 平台 / 开发者 | traces 专用协议,优先于通用 protocol。 | +| `CLOUD_MONITOR_OTLP_TRACES_HEADERS` | 条件必传(与通用 headers 二选一) | 无 | 是 | 平台 | traces 专用 headers,优先于通用 headers;设置后必须包含 `Ksc-Appkey`。 | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | 条件必传 | 无 | 否 | 平台 / 开发者 | 通用 OTLP HTTP backend endpoint;未设置 traces 专用 endpoint 时,KsADK 会派生 `/v1/traces`。 | | `OTEL_EXPORTER_OTLP_PROTOCOL` | 否 | 无 | 否 | 平台 / 开发者 | 通用 OTLP 协议;KsADK 自动 HTTP exporter 当前支持 `http/protobuf`。 | | `OTEL_EXPORTER_OTLP_HEADERS` | 否 | 无 | 是 | 平台 / 开发者 | 通用 OTLP headers,逗号分隔,值按 URL encoding;可能包含 `Authorization`。 | | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | 否 | 无 | 否 | 平台 / 开发者 | traces 专用 endpoint;设置后优先于通用 endpoint。 | @@ -285,8 +288,9 @@ | `KSADK_LTM_BACKEND` | Long-term memory | 否 | `local` | 无 | 否 | 开发者 / 平台 | 否 | LTM backend。 | | `KSADK_LTM_HTTP_URL` | HTTP LTM | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | HTTP LTM URL。 | | `KSADK_LTM_HTTP_TOKEN` | HTTP LTM | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | HTTP LTM token。 | -| `KSADK_LTM_ACCESS_KEY` | SDK LTM | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | Secret | 否 | SDK LTM AK。 | -| `KSADK_LTM_SECRET_KEY` | SDK LTM | 条件必传 | 未设置 | `KSYUN_SECRET_KEY` | 是 | Secret | 否 | SDK LTM SK。 | +| `KSADK_LTM_ACCESS_KEY` | SDK LTM | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY`、`KSYUN_ACCESS_KEY_ID` | 是 | Secret | 否 | SDK LTM AK。 | +| `KSADK_LTM_SECRET_KEY` | SDK LTM | 条件必传 | 未设置 | `KSYUN_SECRET_KEY`、`KSYUN_SECRET_ACCESS_KEY` | 是 | Secret | 否 | SDK LTM SK。 | +| `KSADK_LTM_SESSION_TOKEN` | SDK LTM | 否 | 未设置 | `KSYUN_SESSION_TOKEN` | 是 | Secret | 否 | SDK LTM STS 临时会话 token。 | | `KSADK_LTM_REGION` | SDK LTM | 否 | `cn-beijing-6` | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM region。 | | `KSADK_LTM_ENDPOINT` | SDK LTM | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM endpoint。 | | `KSADK_LTM_SCHEME` | SDK LTM | 否 | `https` | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM scheme。 | @@ -310,8 +314,9 @@ | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `KSADK_KB` | Knowledge base | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | AICP knowledge-base 连接配置前缀。 | | `KSADK_KB_DATASET_ID` | Knowledge base | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 存在时启用知识库。 | -| `KSADK_KB_ACCESS_KEY` | Knowledge base | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | Secret | 否 | KB AK。 | -| `KSADK_KB_SECRET_KEY` | Knowledge base | 条件必传 | 未设置 | `KSYUN_SECRET_KEY` | 是 | Secret | 否 | KB SK。 | +| `KSADK_KB_ACCESS_KEY` | Knowledge base | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY`、`KSYUN_ACCESS_KEY_ID` | 是 | Secret | 否 | KB AK。 | +| `KSADK_KB_SECRET_KEY` | Knowledge base | 条件必传 | 未设置 | `KSYUN_SECRET_KEY`、`KSYUN_SECRET_ACCESS_KEY` | 是 | Secret | 否 | KB SK。 | +| `KSADK_KB_SESSION_TOKEN` | Knowledge base | 否 | 未设置 | `KSYUN_SESSION_TOKEN` | 是 | Secret | 否 | KB STS 临时会话 token。 | | `KSADK_KB_ENDPOINT` | Knowledge base | 否 | `aicp.api.ksyun.com` | 无 | 否 | 平台 / 开发者 | 否 | KB endpoint。 | | `KSADK_KB_REGION` | Knowledge base | 否 | `cn-beijing-6` | 无 | 否 | 平台 / 开发者 | 否 | KB region。 | | `KSADK_KB_SCHEME` | Knowledge base | 否 | 内网 endpoint 默认 `http`,其他默认 `https` | 无 | 否 | 平台 / 开发者 | 否 | KB endpoint scheme。 | @@ -331,11 +336,28 @@ | `AGENTENGINE_API_VERSION` | CLI / API client | 否 | 内置版本 | 无 | 否 | 平台 / 开发者 | 否 | 覆盖 AgentEngine API version。 | | `AGENTENGINE_PRE_CONTROL_REGION` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 预发控制面 region 覆盖。 | | `AGENTENGINE_PRE_CUSTOM_SOURCE` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 预发 custom source 覆盖。 | -| `KSADK_A2A_SPACE_IDS` | A2A Runtime | 条件必传 | 未设置 | 无 | 否 | 部署层 / 平台 | 否 | Runtime Agent 当前配置的 Space ID JSON 数组,最多 100 个。`A2ASpaceClient.from_env()` 仅在数组恰有一个元素时自动选择;多 Space 必须传 `space_id`,且服务端 membership 才是授权事实。 | +| `KSADK_A2A_ACCOUNT_ID` | A2A Runtime | 条件必传 | 未设置 | 无 | 否 | 部署层 / 平台 | 否 | Runtime 归属账号 id(ar-* agent 的 account),v2 inbound 身份校验需要。 | +| `KSADK_A2A_AGENT_ID` | A2A Runtime | 条件必传 | 未设置 | 无 | 否 | 部署层 / 平台 | 否 | 已注册 A2A Agent 的 id,注册后由 reconciler 注入;当前平台生成 `a2a-agent-*`,调用方应按不透明字符串传递。v2 完整 inbound JSON-RPC 装配需要此值,v1 discovery-only card 不依赖它。 | +| `KSADK_A2A_AGENT_NAME` | A2A Runtime | 否 | fallback `AGENTENGINE_MANAGED_RUNTIME_NAME` → `KSADK_A2A_RUNTIME_ID` | 无 | 否 | 部署层 / 平台 | 否 | AgentCard 展示名称;普通 Code runtime 无 `AGENTENGINE_MANAGED_RUNTIME_NAME` 时由部署层用 `agents.name` 注入。 | +| `KSADK_A2A_AGENT_VERSION` | A2A Runtime | 否 | `0.1.0` | 无 | 否 | 部署层 / 平台 | 否 | AgentCard 业务版本,区别于 runtime 镜像版本(`AGENTENGINE_MANAGED_RUNTIME_VERSION`)。 | +| `KSADK_A2A_SPACE_ID` | A2A Runtime | 条件必传 | 未设置 | 无 | 否 | 部署层 / 平台 | 否 | Runtime Agent 当前选择的主 Space ID。`A2ASpaceClient.from_env()` 优先读取此值;ID 按不透明字符串处理,不依赖固定前缀。 | +| `KSADK_A2A_SPACE_IDS` | A2A Runtime | 否 | 未设置 | `KSADK_A2A_SPACE_ID` 缺失时兼容读取 | 否 | 部署层 / 平台 | 否 | 兼容旧部署的 Space ID JSON 数组,最多 100 个。仅在数组恰有一个元素时自动选择;多 Space 必须显式传 `space_id`,且服务端 membership 才是授权事实。 | | `KSADK_A2A_CONTROL_PLANE_URL` | A2A Runtime | 条件必传 | 未设置 | 无 | 否 | 部署层 / 平台 | 否 | Runtime internal A2A Action 基地址;不自动探测、不回退 `AGENTENGINE_SERVER_URL`。 | | `KSADK_A2A_ENABLE_PUBLIC_EGRESS` | A2A Runtime | 否 | 由 `Network.EnablePublicAccess` 最终值派生;未注入时 SDK 按 `false` fail-closed | 无 | 否 | 部署层 / 平台 | 否 | 只控制 `external_public`;CreateAgent 的 `Network.EnablePublicAccess` 缺省为 `true`。`external_vpc` 由独立 VPC policy 决定。 | | `KSADK_A2A_EVENT_OUTBOX_PATH` | A2A Runtime | 否 | `.agentengine/a2a_event_outbox.sqlite3` | 无 | 否 | 部署层 / 平台 | 否 | A2A Task event durable outbox 的 SQLite 路径;生产部署应放在 Runtime 可写持久卷,不保存 permit、JWT 或 credential。 | +| `KSADK_A2A_INTERNAL_BASE_URL` | A2A Runtime | 条件必传 | `http://localhost:8080` | 无 | 否 | 部署层 / 平台 | 否 | AgentCard `base_url` 用的内网 HTTP(S) origin;必须是绝对 origin(`scheme://host[:port]`),无 path/query/fragment、无尾 `/`。server 侧会重写为 gateway 地址。 | +| `KSADK_A2A_RUNTIME_ID` | A2A Runtime | 条件必传 | 未设置 | 无 | 否 | 部署层 / 平台 | 否 | 托管 Agent runtime 资源 id(`ar-*`,即 `agents.id`)。v1 discovery-only card 只要此值非空即挂载,部署期注入、注册前可用。 | +| `KSADK_A2A_TENANT_ID` | A2A Runtime | 否 | fallback `KSADK_A2A_ACCOUNT_ID` | 无 | 否 | 部署层 / 平台 | 否 | Runtime 租户 id;未注入时回退到 account id。 | | `KSADK_A2A_TOKEN_DIR` | A2A Runtime | 否 | `/var/run/secrets/agentengine/a2a` | 无 | 否 | 部署层 / token sidecar | 否 | audience JWT 目录,包含 `a2a-registry.jwt`、`a2a-task-sink.jwt`、`credential-broker.jwt`、`a2a-gateway.jwt`;文件必须为 regular、非 symlink、`0400`、最大 16 KiB。 | +| `KSADK_A2A_ACCESS_KEY` | A2A Runtime / KOP | 条件必传 | 未设置 | 无 | 是 | 部署层 / 平台 | 否 | A2A KOP 签名 access key;未设置时回退 `KSYUN_ACCESS_KEY`。 | +| `KSADK_A2A_SECRET_KEY` | A2A Runtime / KOP | 条件必传 | 未设置 | 无 | 是 | 部署层 / 平台 | 否 | A2A KOP 签名 secret key;未设置时回退 `KSYUN_SECRET_KEY`。 | +| `KSADK_A2A_SERVICE` | A2A Runtime / KOP | 否 | `aicp` | 无 | 否 | 部署层 / 平台 | 否 | A2A KOP 签名 service 名称。 | +| `KSADK_A2A_SERVICE_URL` | A2A Runtime / KOP | 否 | 自动探测(inner 优先,回落 public) | 无 | 否 | 部署层 / 平台 | 否 | A2A 控制面 service URL(KOP 公网 API);未设置时按 AICP 探测默认值。 | +| `KSADK_A2A_SERVICE_TOKEN` | A2A Runtime / KOP | 否 | 未设置 | 无 | 是 | 部署层 / 平台 | 否 | A2A 控制面 Bearer token;平台注入或留空走 AK/SK 签名。 | +| `KSADK_A2A_SERVICE_ENDPOINT` | A2A Runtime / KOP | 否 | 未设置 | 无 | 否 | 部署层 / 平台 | 否 | A2A service endpoint hostname,配合 `KSADK_A2A_SERVICE_SCHEME` 构造 base URL。 | +| `KSADK_A2A_SERVICE_SCHEME` | A2A Runtime / KOP | 否 | `https` | 无 | 否 | 部署层 / 平台 | 否 | A2A service URL scheme(`http`/`https`)。 | +| `KSADK_A2A_SERVICE_REGION` | A2A Runtime / KOP | 否 | 回退 `KSYUN_REGION` → `cn-beijing-6` | 无 | 否 | 部署层 / 平台 | 否 | A2A KOP 签名 region。 | +| `KSADK_EVAL_JUDGE_API_KEY` | 评测 / LLM Judge | 条件必传 | 未设置 | 无 | 是 | 开发者 / 平台 | 否 | LLM Judge 评测后端的 API key。 | | `KSADK_A2UI_GENERATION_TIMEOUT_SECONDS` | A2UI / AG-UI Runtime | 否 | `20` | 无 | 否 | 平台 / 开发者 | 否 | A2UI 结构化生成的超时秒数;有效值会被限制在 `1` 到 `120`。 | | `KSADK_AICP_ENDPOINT_MODE` | AICP resolver | 否 | `auto` | 无 | 否 | 平台 / 开发者 | 否 | AICP endpoint 选择策略,支持 `auto/detect/internal/inner/public`。内网环境可显式设为 `inner`,跳过自动探测。 | | `AGENTENGINE_MODEL_ALLOWLIST` | CLI model / OpenClaw | 否 | 未设置 | `OPENCLAW_MODEL_ALLOWLIST` | 否 | 平台 / 开发者 | 否 | 模型列表过滤。OpenClaw 场景优先使用 `OPENCLAW_MODEL_ALLOWLIST`。 | @@ -344,12 +366,19 @@ | `KSADK_UI_PATH` | 本地 Web UI / Runtime bootstrap | 否 | `/` | 无 | 否 | 开发者 / 平台 | 否 | 自定义 UI 挂载路径,例如 `/research`。 | | `KSADK_UI_URL` | Runtime bootstrap | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 外部自定义 UI URL。 | | `KSADK_UI_BUNDLE_PATH` | Runtime bootstrap | 否 | 自动探测 `research-ui/dist` | 无 | 否 | 开发者 / 平台 | 否 | 自定义 UI 静态 bundle 相对项目路径。 | -| `KSADK_WEB_VERSION` | Hosted Web UI static sync | 否 | `0.3.0` | 可显式设置已发布版本 | 否 | 构建环境 / 发版负责人 | 否 | `make sync-ksadk-web-static` 使用的 `@kingsoftcloud/ksadk-web` npm 版本。wheel 构建必须固定一个已发布版本;升级此值前先发布并验证对应的 npm 包。 | +| `KSADK_WEB_VERSION` | Hosted Web UI static sync | 否 | `0.3.1` | 可显式设置已发布版本 | 否 | 构建环境 / 发版负责人 | 否 | `make sync-ksadk-web-static` 使用的 `@kingsoftcloud/ksadk-web` npm 版本。wheel 构建必须固定一个已发布版本;升级此值前先发布并验证对应的 npm 包。 | | `KSADK_WEB_PACKAGE` | Hosted Web UI static sync | 否 | `@kingsoftcloud/ksadk-web` | 无 | 否 | 构建环境 / 开发者 | 否 | 本地 UI static 同步使用的 npm 包名。 | | `KSADK_WEB_TARBALL_NAME` | Hosted Web UI static sync | 否 | 根据 `KSADK_WEB_VERSION` 派生 | 无 | 否 | 构建环境 | 否 | 仅在设置 `KSADK_WEB_RELEASE_URL` 时作为下载保存文件名;npm pack 模式会使用 npm 返回的真实 tarball 文件名。 | | `KSADK_WEB_RELEASE_URL` | Hosted Web UI static sync | 否 | 未设置 | 无 | 否 | 构建环境 / 开发者 | 否 | 可选兼容兜底。设置后跳过 npm pack,改从该 tarball URL 下载。 | | `KSADK_WEB_CACHE_DIR` | Hosted Web UI static sync | 否 | `.cache/ksadk-web` | 无 | 否 | 构建环境 / 开发者 | 否 | KsADK Web 包解压缓存目录。 | +| `KSADK_CODEX_APPROVAL` | Codex runtime | 否 | 由沙箱策略决定 | 无 | 否 | 开发者 / Studio | 否 | Codex 审批模式;只读场景默认拒绝写操作,Studio 可按轮次传入审批等级。 | +| `KSADK_CODEX_HOME` | Codex runtime | 否 | 自动隔离到工作区 | 无 | 否 | 开发者 | 否 | 显式覆盖原生 Codex 的 HOME 目录;普通用户无需设置。 | +| `KSADK_CODEX_ISOLATE_HOME` | Codex runtime | 否 | `1` | 无 | 否 | 开发者 / 测试 | 否 | 默认隔离 Codex 状态;仅调试时可设为 `0` 复用进程 HOME。 | +| `KSADK_CODEX_SANDBOX` | Codex runtime | 否 | `read_only` | 无 | 否 | 开发者 / Studio | 否 | Codex 沙箱模式:`read_only`、`workspace_write` 或 `full_access`。 | | `KSADK_CODEX_USE_PROXY` | Codex runtime | 否 | 自动探测 | 无 | 否 | 开发者 / 平台 | 否 | `1` 强制启用本地 Responses-to-Chat proxy,`0` 强制直连;未设置时仅对自定义上游进行保守探测。 | +| `KSADK_STUDIO_NO_SECURITY` | AgentKit Studio | 否 | `0` | 无 | 否 | 测试环境 | 否 | 仅受控自动化测试可设为 `1`;正常启动必须保留 loopback session 与 CSRF 校验。 | +| `KSADK_STUDIO_SESSION_TOKEN` | AgentKit Studio | 否 | 随机生成 | 无 | 是 | CLI / 测试 Secret | 否 | 显式指定本地浏览器 session token;正常启动由 CLI 随机生成并写入启动 URL。 | +| `KSADK_STUDIO_TRACE_CONTENT` | AgentKit Studio | 否 | `1` | 无 | 否 | 开发者 / Studio 设置 | 否 | 是否保存 Trace 事件正文;设为 `0` 时只保留排障所需元数据。 | | `KSADK_PROXY_UPSTREAM_BASE` | Codex model proxy | 否 | `OPENAI_BASE_URL` / `OPENAI_API_BASE` | 无 | 否 | 开发者 / 平台 | 否 | Codex proxy 上游 base URL 覆盖。 | | `KSADK_PROXY_UPSTREAM_KEY` | Codex model proxy | 否 | `OPENAI_API_KEY` | 无 | 是 | 开发者 / 平台 | 否 | Codex proxy 上游凭据覆盖。 | | `KSADK_PROXY_TOKEN` | Codex model proxy | 否 | 运行时生成 | 无 | 是 | SDK 内部 | 否 | 本地回环 proxy 与 Codex 子进程之间的 bearer token;通常不应手动设置。 | @@ -421,31 +450,21 @@ ## 11. 可观测性 -!!! new "0.6.7 新增:CloudMonitor 双写" - 0.6.7 起 `setup_tracing()` 自动探测 `CLOUD_MONITOR_*` 环境变量:当检测到 CloudMonitor 配置时,会并行挂载 CloudMonitor OTLP exporter 与 CloudMonitor Langfuse SDK CallbackHandler。这两个通道与自建 Langfuse(`LANGFUSE_*`)和标准 OTLP(`OTEL_EXPORTER_OTLP_*`)互不压制,可以同时上报到多个后端,便于在同一份 trace 上叠加平台可观测与自建链路。 +!!! new "标准 OTLP 双写" + `setup_tracing()` 只消费标准主路 `OTEL_EXPORTER_OTLP_*` 和平台次路 `CLOUD_MONITOR_OTLP_*`。两个 exporter 共享同一批 OTel spans,不再存在 Langfuse SDK callback 通路。托管 Agent 通过 CLI 或控制台创建时默认开启;只有显式 `--no-observability` 或在控制台关闭才禁用。平台托管的 endpoint、headers 和 protocol 不应通过业务 `--env` 覆盖。 | 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `LANGFUSE_PUBLIC_KEY` | Tracing / Runtime | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | Langfuse public key。 | -| `LANGFUSE_SECRET_KEY` | Tracing / Runtime | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | Langfuse secret key。 | -| `LANGFUSE_BASE_URL` | Tracing / Runtime | 否 | 未设置 | `LANGFUSE_HOST` | 否 | 平台 / 开发者 | 否 | Langfuse endpoint。 | -| `LANGFUSE_HOST` | Tracing / Runtime | 否 | 未设置 | `LANGFUSE_BASE_URL` | 否 | 兼容旧配置 | 否 | Langfuse endpoint 旧变量。 | -| `LANGFUSE_PROJECT_ID` | Tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | Langfuse project id。 | -| `LANGFUSE_USE_CALLBACK` | Tracing | 否 | 未设置 | 无 | 否 | 开发者 | 否 | 是否启用 Langfuse callback。 | | `LANGCHAIN_TRACING_V2` | LangChain tracing | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | LangChain v2 tracing 开关。 | | `LANGCHAIN_VERBOSE` | Runtime image | 否 | `true` | 无 | 否 | 开发者 / 平台 | 否 | 模板运行时 LangChain verbose 开关。 | -| `CLOUD_MONITOR_APP_KEY` | CloudMonitor tracing | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | 云监控 OTLP AppKey;启用 CloudMonitor OTLP 上报时需要。 | -| `CLOUD_MONITOR_OTLP_ENABLED` | CloudMonitor tracing | 否 | 自动判断 | 无 | 否 | 平台 / 开发者 | 否 | 显式启用或禁用 CloudMonitor OTLP exporter。 | +| `CLOUD_MONITOR_APP_KEY` | CloudMonitor tracing | 否 | 未设置 | 一个版本的过渡 fallback | 是 | 旧平台 Secret | 否 | 已废弃;仅当 traces 和通用 headers 变量都整体缺失时翻译为 `Ksc-Appkey`。 | | `CLOUD_MONITOR_OTLP_ENDPOINT` | CloudMonitor tracing | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor 通用 OTLP HTTP endpoint;未设置 traces endpoint 时会派生 `/v1/traces`。 | | `CLOUD_MONITOR_OTLP_PROTOCOL` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor 通用 OTLP 协议,当前支持 `http/protobuf`。 | -| `CLOUD_MONITOR_OTLP_HEADERS` | CloudMonitor tracing | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | CloudMonitor OTLP 附加 headers,逗号分隔且 URL encoded。 | +| `CLOUD_MONITOR_OTLP_HEADERS` | CloudMonitor tracing | 条件必传(与 traces headers 二选一) | 未设置 | 旧 AppKey fallback | 是 | 平台 | 否 | CloudMonitor OTLP 通用 headers,逗号分隔且 RFC 3986 encoded;必须包含 `Ksc-Appkey`。 | | `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor traces 专用 endpoint,优先于通用 endpoint。 | +| `CLOUD_MONITOR_OTLP_TRACES_HEADERS` | CloudMonitor tracing | 条件必传(与通用 headers 二选一) | 未设置 | 无 | 是 | 平台 | 否 | CloudMonitor traces 专用 OTLP headers,优先于通用 headers;设置后必须包含 `Ksc-Appkey`,否则 fail closed。 | | `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor traces 专用协议,优先于通用 protocol。 | -| `CLOUD_MONITOR_LANGFUSE_ENABLED` | CloudMonitor Langfuse callback | 否 | 自动判断 | 无 | 否 | 平台 / 开发者 | 否 | 显式启用或禁用 CloudMonitor Langfuse SDK callback。 | -| `CLOUD_MONITOR_LANGFUSE_HOST` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | `CLOUD_MONITOR_OTLP_ENDPOINT` | 否 | 平台 / 开发者 | 否 | CloudMonitor AppMonitor Langfuse SDK host。 | -| `CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | CloudMonitor AppMonitor Langfuse public key。 | -| `CLOUD_MONITOR_LANGFUSE_SECRET_KEY` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | CloudMonitor AppMonitor Langfuse secret key。 | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTel | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | OTel Collector endpoint;未设置 traces 专用 endpoint 时,KsADK 会派生 `/v1/traces`。 | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTel | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 通用 OTLP HTTP backend endpoint;未设置 traces 专用 endpoint 时,KsADK 会派生 `/v1/traces`。 | | `OTEL_EXPORTER_OTLP_PROTOCOL` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 通用 OTLP 协议;KsADK 自动 HTTP exporter 当前支持 `http/protobuf`。 | | `OTEL_EXPORTER_OTLP_HEADERS` | OTel | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | 通用 OTLP headers,逗号分隔,值按 URL encoding;可能包含 `Authorization`。 | | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | traces 专用 endpoint;设置后优先于通用 endpoint。 | @@ -676,7 +695,7 @@ Hermes / OpenClaw 有大量镜像启动和安全策略变量,本文只列常 | 业务代码读取的变量,例如 `APP_ENV`、`DATABASE_URL`、`REDIS_URL`、`MY_SERVICE_TOKEN` | 是 | 否 | 由业务方自己定义,KsADK 不做含义约束。 | | Agent 依赖的第三方工具变量,例如某业务 API token | 是 | 否 | 可以通过部署环境注入,但不属于 KsADK 标准契约。 | | SDK/镜像内置扩展读取的第三方 token,例如 `TAVILY_API_KEY`、`FIRECRAWL_API_KEY`、`MEM0_API_KEY` | 否 | 部分写入 | 只有被 KsADK runtime、Hermes/OpenClaw 模板或内置 skill 明确读取的变量才列入本文。 | -| 平台或 SDK 读取的变量,例如 `KSADK_*`、`KSYUN_*`、`E2B_*`、`OPENAI_*`、`LANGFUSE_*` | 否 | 是 | 本文维护常见和核心变量。 | +| 平台或 SDK 读取的变量,例如 `KSADK_*`、`KSYUN_*`、`E2B_*`、`OPENAI_*`、`OTEL_*` | 否 | 是 | 本文维护常见和核心变量。 | | 镜像模板内部变量,例如大量 `OPENCLAW_*` / `HERMES_*` 高级开关 | 否 | 部分写入 | 本文只列常见运行时可配置项,完整列表以对应模板 README/bootstrap 为准。 | ## 16. 配置建议 diff --git a/export-manifest.json b/export-manifest.json index d940e4e4..804c2720 100644 --- a/export-manifest.json +++ b/export-manifest.json @@ -1,9 +1,9 @@ { - "generatedAt": "2026-07-29T07:37:44.862331+00:00", + "generatedAt": "2026-08-13T10:42:08.520085+00:00", "targetRepository": "https://github.com/kingsoftcloud/ksadk-python", "documentation": "https://kingsoftcloud.github.io/ksadk-python/", - "exportPathCount": 662, - "excludedPathCount": 294, + "exportPathCount": 826, + "excludedPathCount": 364, "excludedPaths": [ "docs/A2UI-agent驱动UI技术方案.md", "docs/Agent 开发者上下文接入指南.md", @@ -11,6 +11,8 @@ "docs/a2ui-v1-alignment-proposal.md", "docs/adk-multi-version-compat.md", "docs/adk-resume-integration-design.md", + "docs/agentkit-local-studio-phase1-delivery-design.md", + "docs/agentkit-studio-frontend-design-system-v1.md", "docs/archive/kb-memory/knowledge_base_integration_plan.md", "docs/archive/kb-memory/knowledge_base_test_report.md", "docs/archive/kb-memory/memory_adk_test_practice.md", @@ -41,6 +43,8 @@ "docs/ksadk开源准备计划.md", "docs/ksadk技术设计.md", "docs/ksadk环境变量参考.md", + "docs/ksadk端上Agent观测与评测技术评审方案.md", + "docs/ksadk端上Agent观测与评测落地技术方案.md", "docs/openclaw_client_one_click_deploy.html", "docs/openclaw_gateway_channel_flow.svg", "docs/openclaw_gateway_channel_flow_hd.png", @@ -52,17 +56,22 @@ "docs/preview/images/claw_robot.png", "docs/preview/images/wps_support_group.jpg", "docs/prompt-driven-agent-creation-draft.md", + "docs/qoder-cloud-agent-benchmark-and-phase1-redesign.md", "docs/reference/ksadk技术设计.md", "docs/reference/远程Agent运行时接口说明.md", - "docs/responses-chat-protocol-proxy-plan.md", "docs/runtime-foundation-freeze-v1.md", "docs/superpowers/plans/2026-04-16-hosted-hermes-gateway.md", "docs/superpowers/plans/2026-04-20-workspace-files-pvc-implementation.md", "docs/superpowers/plans/2026-05-07-thinking-user-control-and-e2e-plan.md", "docs/superpowers/plans/2026-06-04-ksadk-0.6.2-public-candidate-audit.md", "docs/superpowers/plans/2026-06-04-otel-first-observability-release.md", + "docs/superpowers/plans/2026-08-04-agentkit-studio-otel-trace-explorer-rewrite.md", + "docs/superpowers/plans/2026-08-04-runtime-adapter-web-unification.md", + "docs/superpowers/plans/2026-08-11-runtime-event-schema-v2-release.md", "docs/superpowers/specs/2026-04-18-ksadk-support-model-design.md", "docs/superpowers/specs/2026-04-19-agent-workspace-file-service-design.md", + "docs/superpowers/specs/2026-08-04-agentkit-studio-unified-runtime-design.md", + "docs/superpowers/specs/2026-08-11-runtime-event-v2-v1-compatibility-design.md", "docs/veadk-benchmark-and-iteration-plan.md", "docs/工作区文件技术设计.md", "docs/平台可观测与用户反馈设计方案.md", @@ -70,8 +79,6 @@ "docs/自定义请求数据透传方案.md", "docs/记忆使用指南.md", "docs/远程Agent运行时接口说明.md", - "offline-packages/ksadk-0.7.0-windows-x64.tar.gz", - "offline-packages/windows-x64/ksadk-0.7.0-py3-none-any.whl", "scripts/ci-frontend-check.sh", "scripts/debug_aicp_memory.py", "scripts/test_ks3_upload.py", @@ -97,9 +104,11 @@ "skills/agentengine-openclaw-oneclick-deploy/references/dashboard-links.md", "skills/agentengine-openclaw-oneclick-deploy/references/openclaw-lifecycle.md", "skills/agentengine-openclaw-oneclick-deploy/references/troubleshooting.md", + "tests/__init__.py", "tests/a2a/compose.yaml", "tests/a2a/pg_process_worker.py", "tests/a2a/process_server.py", + "tests/a2a/test_a2a_dependency_contract.py", "tests/a2a/test_a2a_discovery.py", "tests/a2a/test_a2a_protocol_e2e.py", "tests/a2a/test_card.py", @@ -110,6 +119,7 @@ "tests/a2a/test_executor_cancel_honesty.py", "tests/a2a/test_executor_resume_runtime_adapter.py", "tests/a2a/test_external_transport.py", + "tests/a2a/test_managed_a2a_card.py", "tests/a2a/test_managed_bootstrap.py", "tests/a2a/test_owner_resolver.py", "tests/a2a/test_pg_process_recovery.py", @@ -126,7 +136,13 @@ "tests/agui/test_protocol_contract.py", "tests/agui/test_runtime_adapter.py", "tests/agui/test_runtime_preprocessing.py", + "tests/architecture/test_python_module_size.py", + "tests/architecture/test_runtime_boundaries.py", + "tests/builders/test_runtime_entrypoint_templates.py", "tests/cli/test_cli_alignment.py", + "tests/cli/test_cmd_a2a_runtime_adapter.py", + "tests/cli/test_cmd_run_runtime_adapter.py", + "tests/cli/test_cmd_web_runtime_adapter.py", "tests/cli/test_run_chain_e2e.py", "tests/codex/fake_app_server.py", "tests/codex/test_proxy_injection.py", @@ -161,19 +177,27 @@ "tests/model_proxy/test_server.py", "tests/model_proxy/test_streamer.py", "tests/model_proxy/test_transform.py", - "tests/runners/test_adapter_contract.py", "tests/runners/test_adapter_interface.py", "tests/runners/test_adk_approval_surface.py", + "tests/runners/test_adk_runner.py", "tests/runners/test_checkpoint_capability_honesty.py", - "tests/runners/test_codex_runtime.py", + "tests/runners/test_codex_runtime_adapter.py", "tests/runners/test_codex_sdk_surface.py", "tests/runners/test_langchain_hitl_langgraph.py", "tests/runners/test_runtime_resume_cancel_e2e.py", + "tests/runtime/test_conversation_execution.py", + "tests/runtime/test_default_factory.py", + "tests/runtime/test_executor.py", + "tests/runtime/test_preprocessing.py", + "tests/runtime/test_registry.py", + "tests/runtime/test_responses_streaming.py", "tests/server/test_app_factory.py", "tests/server/test_app_factory_a2a.py", "tests/server/test_app_factory_agui.py", "tests/server/test_app_factory_isolation.py", "tests/server/test_route_manifest_parity.py", + "tests/server/test_runtime_executor_routes.py", + "tests/server/test_server_exports_runtime_adapter.py", "tests/server/test_ui_bootstrap_agui.py", "tests/skills/__init__.py", "tests/skills/test_adk_runner_skill_runtime.py", @@ -189,8 +213,46 @@ "tests/snapshots/help_snapshots.txt", "tests/snapshots/resource_output_snapshots.txt", "tests/snapshots/workflow_help_snapshots.txt", + "tests/studio/__init__.py", + "tests/studio/e2e/fake_studio_server.py", + "tests/studio/e2e/fixtures/review_workspace/src/demo.py", + "tests/studio/e2e/studio_browser_smoke.py", + "tests/studio/e2e/studio_e2e_support.py", + "tests/studio/e2e/studio_responsive_smoke.py", + "tests/studio/e2e/test_codex_xingliu_demo.py", + "tests/studio/runtime_adapter_fixtures.py", + "tests/studio/test_api.py", + "tests/studio/test_authoring.py", + "tests/studio/test_builder.py", + "tests/studio/test_cli_studio.py", + "tests/studio/test_codex_api.py", + "tests/studio/test_codex_builder.py", + "tests/studio/test_codex_manifest.py", + "tests/studio/test_codex_run_spec.py", + "tests/studio/test_codex_static.py", + "tests/studio/test_contracts.py", + "tests/studio/test_evaluation_cloud.py", + "tests/studio/test_evaluation_shell.py", + "tests/studio/test_event_store.py", + "tests/studio/test_model_client.py", + "tests/studio/test_multi_runtime_agents.py", + "tests/studio/test_operations.py", + "tests/studio/test_otel_trace.py", + "tests/studio/test_real_model_e2e.py", + "tests/studio/test_resource_catalog.py", + "tests/studio/test_run_service.py", + "tests/studio/test_settings.py", + "tests/studio/test_shared_web.py", + "tests/studio/test_skill_discovery.py", + "tests/studio/test_studio_mcp_runtime.py", + "tests/studio/test_style_system.py", + "tests/studio/test_templates.py", + "tests/studio/test_validator_compiler.py", + "tests/studio/test_workspace_repository.py", + "tests/test_a2a_agent_card.py", "tests/test_a2a_cli.py", "tests/test_a2a_integration.py", + "tests/test_a2a_toolset.py", "tests/test_adk_resilient_session_service.py", "tests/test_agent.py", "tests/test_agent_access.py", @@ -218,6 +280,7 @@ "tests/test_cmd_create_from_agent.py", "tests/test_cmd_dashboard_fallback.py", "tests/test_cmd_deploy_no_cache.py", + "tests/test_cmd_eval.py", "tests/test_cmd_files.py", "tests/test_cmd_hermes.py", "tests/test_cmd_invoke.py", @@ -238,6 +301,13 @@ "tests/test_deploy_integration.py", "tests/test_detector_accuracy.py", "tests/test_error_utils_hints.py", + "tests/test_evaluation_a2a_adapter.py", + "tests/test_evaluation_auto_evaluators.py", + "tests/test_evaluation_contracts.py", + "tests/test_evaluation_evalset.py", + "tests/test_evaluation_executor.py", + "tests/test_evaluation_storage.py", + "tests/test_evaluation_target.py", "tests/test_events_for_agent.py", "tests/test_help_snapshots.py", "tests/test_hermes_container_builder.py", @@ -245,9 +315,8 @@ "tests/test_hermes_terminal_e2e.py", "tests/test_identity_resolver.py", "tests/test_json_contracts.py", + "tests/test_kop_client.py", "tests/test_ks3_uploader_urls.py", - "tests/test_langfuse_exporter.py", - "tests/test_langfuse_runner_utils.py", "tests/test_langgraph_runner_resume.py", "tests/test_langgraph_runner_skill_runtime.py", "tests/test_local_runtime_reexec.py", @@ -268,9 +337,9 @@ "tests/test_remote_runner.py", "tests/test_resource_output_snapshots.py", "tests/test_runner.py", - "tests/test_runner_langfuse_callbacks.py", "tests/test_runtime_common_memory_backend.py", "tests/test_sandbox_backend.py", + "tests/test_security_boundaries.py", "tests/test_semantic_circuit_breaker.py", "tests/test_server_app_fastapi_compat.py", "tests/test_server_file_upload_parsing.py", @@ -280,6 +349,7 @@ "tests/test_session_continuity.py", "tests/test_session_title.py", "tests/test_sessions_service.py", + "tests/test_settings_otlp_contract.py", "tests/test_setup_environment.py", "tests/test_stm_config.py", "tests/test_storage_defaults.py", @@ -359,6 +429,7 @@ "tests": [ "tests/cli/test_cmd_create_codex.py", "tests/conftest.py", + "tests/runners/test_adapter_contract.py", "tests/runners/test_codex_runner.py", "tests/test_check_approval_record.py", "tests/test_check_publication_state.py", diff --git a/ksadk/a2a/__init__.py b/ksadk/a2a/__init__.py index df51f0fd..7c86d9e8 100644 --- a/ksadk/a2a/__init__.py +++ b/ksadk/a2a/__init__.py @@ -84,6 +84,7 @@ from ksadk.a2a.server import A2AProtocolServer from ksadk.a2a.space_client import ( ENV_A2A_ENABLE_PUBLIC_EGRESS, + ENV_A2A_SPACE_ID, ENV_A2A_SPACE_IDS, ERR_PUBLIC_EGRESS_DISABLED, A2APlatformTask, @@ -151,6 +152,7 @@ "ENV_A2A_CONTROL_PLANE_URL", "ENV_A2A_ENABLE_PUBLIC_EGRESS", "ENV_A2A_EVENT_OUTBOX_PATH", + "ENV_A2A_SPACE_ID", "ENV_A2A_SPACE_IDS", "ENV_A2A_TOKEN_DIR", "ERR_PUBLIC_EGRESS_DISABLED", diff --git a/ksadk/a2a/bootstrap.py b/ksadk/a2a/bootstrap.py index 9a08ddfa..c886622b 100644 --- a/ksadk/a2a/bootstrap.py +++ b/ksadk/a2a/bootstrap.py @@ -241,7 +241,6 @@ def mount( self, app: FastAPI, *, - runner: Any, runtime_adapter: RuntimeAdapter, runtime_type: str, ) -> Any | None: @@ -276,7 +275,6 @@ def mount( ) self._server = add_a2a_protocol_routes( app, - runner, config, task_adapter=task_adapter, task_store=_require(self._task_store, "task_store"), diff --git a/ksadk/a2a/card.py b/ksadk/a2a/card.py index a8fde594..187899b2 100644 --- a/ksadk/a2a/card.py +++ b/ksadk/a2a/card.py @@ -1,117 +1,12 @@ -"""A2A AgentCard 构造 (goal-05,wire 1.0)。 +"""Compatibility exports for the lightweight shared AgentCard builder.""" -契约(`a2a-center-productization-2026-07.md` §2.1): -- AgentCard 使用 ``supportedInterfaces[]``,每项 ``url`` / ``protocolBinding`` / - ``protocolVersion: "1.0"``;**不用顶层 ``url``,不提供 0.3 fallback,不挂 - ``/.well-known/agent.json`` 旧路径**。 -- 我方托管 Agent 7 月优先暴露 ``JSONRPC``,同时挂 SDK 已提供的 ``HTTP+JSON`` 路由。 -- a2a-sdk 1.1.0 的 wire 对象是 protobuf(``a2a_pb2``),非 pydantic。 -""" - -from __future__ import annotations - -from collections.abc import Sequence - -from a2a.types import ( - AgentCapabilities, - AgentCard, - AgentInterface, - AgentSkill, +from ksadk.a2a_card import ( + A2A_PROTOCOL_VERSION, + JSONRPC_PATH, + REST_PATH_PREFIX, + build_agent_card, ) -#: wire 协议版本(契约 §2.1 固定 1.0)。 -A2A_PROTOCOL_VERSION = "1.0" -#: 托管 Agent 的 A2A 路由路径(契约 §3.3:/a2a/jsonrpc 与 /a2a/v1/*)。 -JSONRPC_PATH = "/a2a/jsonrpc" -REST_PATH_PREFIX = "/a2a/v1" - -_DEFAULT_IO_MODES = ["text/plain"] - - -def build_agent_card( - *, - name: str, - base_url: str, - description: str = "", - version: str = "1.0.0", - skills: Sequence[str | AgentSkill] | None = None, - streaming: bool = True, - provider: dict[str, str] | None = None, -) -> AgentCard: - """构造符合 wire 1.0 的 AgentCard(supportedInterfaces)。 - - 参数: - name: Agent 名称。 - base_url: 该 Agent 对外宣告的基础地址(各 interface url 由此拼出)。 - description: 描述。 - version: 业务版本。 - skills: 标准 ``AgentSkill`` 列表,或本地配置使用的技能 id 列表(空则给 general)。 - streaming: 是否声明 streaming 能力。 - provider: 可选 provider 信息(如 {"organization": ..., "url": ...})。 - """ - base = base_url.rstrip("/") - supported_interfaces = [ - AgentInterface( - url=f"{base}{JSONRPC_PATH}", - protocol_binding="JSONRPC", - protocol_version=A2A_PROTOCOL_VERSION, - ), - AgentInterface( - url=f"{base}{REST_PATH_PREFIX}", - protocol_binding="HTTP+JSON", - protocol_version=A2A_PROTOCOL_VERSION, - ), - ] - - card_kwargs: dict = { - "name": name, - "description": description or f"{name} agent powered by ksadk", - "version": version, - "supported_interfaces": supported_interfaces, - "capabilities": AgentCapabilities(streaming=streaming, push_notifications=False), - "default_input_modes": list(_DEFAULT_IO_MODES), - "default_output_modes": list(_DEFAULT_IO_MODES), - "skills": _build_skills(skills), - } - if provider: - from a2a.types import AgentProvider - - card_kwargs["provider"] = AgentProvider( - organization=provider.get("organization", ""), - url=provider.get("url", ""), - ) - return AgentCard(**card_kwargs) - - -def _build_skills(skills: Sequence[str | AgentSkill] | None) -> list[AgentSkill]: - if not skills: - return [ - AgentSkill( - id="general", - name="General", - description="General purpose agent powered by ksadk", - tags=["general"], - ) - ] - result: list[AgentSkill] = [] - for skill in skills: - if isinstance(skill, AgentSkill): - result.append(skill) - continue - if not isinstance(skill, str) or not skill.strip(): - raise TypeError("AgentCard skills must be non-empty strings or AgentSkill objects") - skill_id = skill.strip() - result.append( - AgentSkill( - id=skill_id, - name=skill_id.replace("_", " ").title(), - description=f"Skill: {skill_id}", - tags=[skill_id], - ) - ) - return result - - __all__ = [ "A2A_PROTOCOL_VERSION", "JSONRPC_PATH", diff --git a/ksadk/a2a/card_builder.py b/ksadk/a2a/card_builder.py deleted file mode 100644 index 3ce9f4fa..00000000 --- a/ksadk/a2a/card_builder.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Helpers for building A2A agent cards.""" - -from __future__ import annotations - -from collections.abc import Sequence - -from a2a.types import AgentCapabilities, AgentCard, AgentSkill - -_DEFAULT_IO_MODES = ["text/plain"] - - -class AgentCardBuilder: - """Build an A2A AgentCard for a KsADK runner.""" - - def __init__( - self, - name: str, - url: str, - description: str = "", - skills: Sequence[str] | None = None, - version: str = "1.0.0", - ) -> None: - self.name = name - self.url = url.rstrip("/") or url - self.description = description or f"{name} agent powered by ksadk" - self.skills = list(skills or []) - self.version = version - - def build(self) -> AgentCard: - """Create the final A2A agent card.""" - return AgentCard( - name=self.name, - description=self.description, - url=self.url, - version=self.version, - capabilities=AgentCapabilities( - streaming=True, - push_notifications=False, - ), - default_input_modes=list(_DEFAULT_IO_MODES), - default_output_modes=list(_DEFAULT_IO_MODES), - skills=self._build_skills(), - ) - - def _build_skills(self) -> list[AgentSkill]: - if not self.skills: - return [ - AgentSkill( - id="general", - name="General", - description="General purpose agent powered by ksadk", - tags=["general"], - ) - ] - - return [ - AgentSkill( - id=skill, - name=skill.replace("_", " ").title(), - description=f"Skill: {skill}", - tags=[skill], - ) - for skill in self.skills - ] diff --git a/ksadk/a2a/client.py b/ksadk/a2a/client.py deleted file mode 100644 index 58f599fc..00000000 --- a/ksadk/a2a/client.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Client helpers for invoking remote A2A agents from KsADK code.""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from typing import Any -from uuid import uuid4 - -import httpx -from a2a.client import Client, ClientConfig, ClientFactory -from a2a.types import ( - Message, - MessageSendConfiguration, - Part, - Role, - Task, - TaskArtifactUpdateEvent, - TaskState, - TaskStatusUpdateEvent, - TextPart, -) -from a2a.utils import get_artifact_text, get_message_text - -_FAILED_TASK_STATES = { - TaskState.canceled, - TaskState.failed, - TaskState.rejected, -} - - -def _build_text_message( - text: str, - *, - context_id: str | None = None, - task_id: str | None = None, -) -> Message: - return Message( - role=Role.user, - parts=[Part(root=TextPart(text=text))], - message_id=str(uuid4()), - context_id=context_id, - task_id=task_id, - ) - - -def _task_output(task: Task) -> str: - outputs = [] - for artifact in task.artifacts or []: - artifact_text = get_artifact_text(artifact, delimiter="") - if artifact_text: - outputs.append(artifact_text) - if outputs: - return "".join(outputs) - - if task.status.message is not None: - return get_message_text(task.status.message) - - if task.history: - for message in reversed(task.history): - if message.role == Role.agent: - message_text = get_message_text(message) - if message_text: - return message_text - - return "" - - -def _task_error(task: Task) -> str: - text = _task_output(task) - if text: - return text - return f"Remote A2A task ended in state: {task.status.state.value}" - - -class RemoteA2AClient: - """Thin client wrapper for interacting with a remote A2A endpoint.""" - - def __init__( - self, - endpoint: str, - *, - http_client: httpx.AsyncClient | None = None, - timeout: float = 120.0, - ) -> None: - self.endpoint = endpoint.rstrip("/") or endpoint - self.timeout = timeout - self._http_client = http_client - self._owns_http_client = http_client is None - self._streaming_client: Client | None = None - self._blocking_client: Client | None = None - - async def get_card(self) -> Any: - client = await self._ensure_client(streaming=True) - return await client.get_card() - - async def invoke( - self, - text: str, - *, - context_id: str | None = None, - task_id: str | None = None, - metadata: dict[str, Any] | None = None, - ) -> dict[str, Any]: - client = await self._ensure_client(streaming=False) - message = _build_text_message( - text, - context_id=context_id, - task_id=task_id, - ) - final_task: Task | None = None - - async for event in client.send_message( - message, - configuration=MessageSendConfiguration(blocking=True), - request_metadata=metadata, - ): - if isinstance(event, Message): - return { - "output": get_message_text(event), - "task_id": event.task_id, - "context_id": event.context_id, - "message": event, - } - final_task, _ = event - - if final_task is None: - raise RuntimeError("A2A response did not include a task result") - - self._raise_for_failed_task(final_task) - return { - "output": _task_output(final_task), - "task_id": final_task.id, - "context_id": final_task.context_id, - "task": final_task, - } - - async def stream( - self, - text: str, - *, - context_id: str | None = None, - task_id: str | None = None, - metadata: dict[str, Any] | None = None, - ) -> AsyncIterator[dict[str, Any]]: - client = await self._ensure_client(streaming=True) - message = _build_text_message( - text, - context_id=context_id, - task_id=task_id, - ) - emitted_output = False - latest_task: Task | None = None - - async for event in client.send_message( - message, - configuration=MessageSendConfiguration(blocking=True), - request_metadata=metadata, - ): - if isinstance(event, Message): - text_output = get_message_text(event) - if text_output: - emitted_output = True - yield { - "delta": text_output, - "type": "text", - "task_id": event.task_id, - "context_id": event.context_id, - } - return - - latest_task, update = event - if isinstance(update, TaskArtifactUpdateEvent): - artifact_text = get_artifact_text(update.artifact) - if artifact_text: - emitted_output = True - yield { - "delta": artifact_text, - "type": "text", - "task_id": update.task_id, - "context_id": update.context_id, - } - continue - - if isinstance(update, TaskStatusUpdateEvent) and update.final: - self._raise_for_failed_task(latest_task) - - if latest_task is None: - return - - self._raise_for_failed_task(latest_task) - if emitted_output: - return - - task_text = _task_output(latest_task) - if task_text: - yield { - "delta": task_text, - "type": "text", - "task_id": latest_task.id, - "context_id": latest_task.context_id, - } - - async def close(self) -> None: - if self._owns_http_client and self._http_client is not None: - await self._http_client.aclose() - self._http_client = None - self._streaming_client = None - self._blocking_client = None - - async def __aenter__(self) -> "RemoteA2AClient": - await self._ensure_client(streaming=True) - return self - - async def __aexit__(self, *_: Any) -> None: - await self.close() - - async def _ensure_client(self, *, streaming: bool) -> Client: - cached_client = ( - self._streaming_client if streaming else self._blocking_client - ) - if cached_client is not None: - return cached_client - - if self._http_client is None: - self._http_client = httpx.AsyncClient(timeout=self.timeout) - - client = await ClientFactory.connect( - self.endpoint, - client_config=ClientConfig( - httpx_client=self._http_client, - streaming=streaming, - polling=False, - accepted_output_modes=["text/plain"], - ), - ) - if streaming: - self._streaming_client = client - else: - self._blocking_client = client - return client - - @staticmethod - def _raise_for_failed_task(task: Task) -> None: - if task.status.state in _FAILED_TASK_STATES: - raise RuntimeError(_task_error(task)) - - -class RemoteA2AAgent: - """Runner-like adapter for using a remote A2A endpoint inside KsADK flows.""" - - def __init__( - self, - endpoint: str, - *, - name: str = "remote_a2a_agent", - http_client: httpx.AsyncClient | None = None, - timeout: float = 120.0, - ) -> None: - self.name = name - self._client = RemoteA2AClient( - endpoint=endpoint, - http_client=http_client, - timeout=timeout, - ) - - async def get_card(self) -> Any: - return await self._client.get_card() - - async def invoke(self, input_data: dict[str, Any]) -> dict[str, Any]: - response = await self._client.invoke( - str(input_data.get("input", "")), - context_id=input_data.get("context_id") or input_data.get("session_id"), - task_id=input_data.get("task_id"), - metadata=self._build_metadata(input_data), - ) - return { - "output": response["output"], - "task_id": response.get("task_id"), - "context_id": response.get("context_id"), - } - - async def stream( - self, - input_data: dict[str, Any], - ) -> AsyncIterator[dict[str, Any]]: - async for event in self._client.stream( - str(input_data.get("input", "")), - context_id=input_data.get("context_id") or input_data.get("session_id"), - task_id=input_data.get("task_id"), - metadata=self._build_metadata(input_data), - ): - yield event - - async def close(self) -> None: - await self._client.close() - - @staticmethod - def _build_metadata(input_data: dict[str, Any]) -> dict[str, Any] | None: - metadata = dict(input_data.get("metadata") or {}) - - state = input_data.get("state") - if isinstance(state, dict) and state: - metadata["state"] = dict(state) - - branch = input_data.get("branch") - if isinstance(branch, str) and branch: - metadata["branch"] = branch - - return metadata or None diff --git a/ksadk/a2a/control_plane.py b/ksadk/a2a/control_plane.py index d4e5b17e..7e56fdbf 100644 --- a/ksadk/a2a/control_plane.py +++ b/ksadk/a2a/control_plane.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import base64 import binascii import errno @@ -9,6 +10,7 @@ import os import stat import time +import uuid from abc import ABC, abstractmethod from dataclasses import dataclass, field from pathlib import Path @@ -19,6 +21,7 @@ from google.protobuf.json_format import ParseDict, ParseError from ksadk.a2a.ids import require_a2a_resource_id +from ksadk.common.kop_client import KOPClient, KOPError ENV_A2A_CONTROL_PLANE_URL = "KSADK_A2A_CONTROL_PLANE_URL" ENV_A2A_TOKEN_DIR = "KSADK_A2A_TOKEN_DIR" @@ -581,6 +584,232 @@ def gateway_token(self) -> str: return self._token_provider.get_token(AUDIENCE_GATEWAY) +class A2AAgentCardClient(A2AControlPlane): + """Card-driven control-plane client for hosted/external A2A discovery. + + 发现面通过 KOP 调 server 对外 Action (ListAToASpaceAgents) 拿到 space 内 + 每个 agent 的完整投影后 AgentCard(含 callable url);调用面直接用 + card.url 发 A2A 请求,不回连 server 做 permit/credential。 + runtime 只需一个 service_url(KOP 对外 API,默认 aicp.api.ksyun.com), + 与 ksadk/skills 同款;card.url 由 server 投影,runtime 不需要知道 gateway 域名。 + + KOP 鉴权(AICP AK/SK 签名)复用 ksadk.common.kop_client.KOPClient, + 和 skill/mcp 共用同一公共层。 + """ + + def __init__( + self, + service_url: str, + *, + service_token: str = "", + httpx_client: httpx.AsyncClient | None = None, + timeout: float = 15.0, + ) -> None: + if not service_url: + raise ValueError("A2AAgentCardClient requires a non-empty service_url") + self._service_url = service_url.rstrip("/") + self._service_token = service_token + self._client = httpx_client + self._timeout = timeout + self._cards_by_agent: dict[str, tuple[AgentCard, str, str]] = {} + self._kop = KOPClient(base_url=service_url, service_token=service_token, timeout=timeout) + + async def _post_action(self, action: str, payload: dict[str, Any]) -> dict[str, Any]: + try: + return await asyncio.to_thread(self._kop.post_action, action, payload) + except KOPError as exc: + raise A2AControlPlaneError( + code=exc.code, + message=exc.message, + error_code=f"KOP_{exc.code}", + retryable=exc.code >= 500, + action=action, # type: ignore[arg-type] + ) from exc + + async def list_space_agents( + self, + space_id: str, + *, + prompt: str | None = None, + skill_id: str | None = None, + include_blocked: bool = False, + if_none_match: str | None = None, + cursor: str | None = None, + page_size: int = 50, + ) -> SpaceAgentPage: + _ = (prompt, skill_id, if_none_match) + payload: dict[str, Any] = { + "A2ASpaceId": space_id, + "Status": "available" if not include_blocked else "", + "PageSize": page_size, + } + if cursor: + payload["PageNumber"] = int(cursor) if str(cursor).isdigit() else 1 + data = await self._post_action("ListAToASpaceAgents", payload) + agents: list[DiscoveredAgent] = [] + for item in data.get("Agents") or []: + if not isinstance(item, dict): + continue + a2a_agent_id = str(item.get("A2AAgentId") or "").strip() + if not a2a_agent_id: + continue + # server 用纯 uuid,ksadk 内部要求 a2a-agent- 前缀;补前缀适配契约。 + if not a2a_agent_id.startswith("a2a-agent-"): + a2a_agent_id = f"a2a-agent-{a2a_agent_id}" + invocation_status = str(item.get("InvocationStatus") or "") + if invocation_status and invocation_status != "available": + continue + card_payload = item.get("AgentCard") + if not isinstance(card_payload, dict): + continue + try: + card = _agent_card_from_wire(card_payload) + except A2AControlPlaneError: + continue + sha = str(item.get("CardSha256") or "") + version_id = str(item.get("VersionId") or "") or sha + if version_id and not version_id.startswith("a2a-version-"): + version_id = f"a2a-version-{version_id}" + source = str(item.get("Source") or "hosted") + self._cards_by_agent[a2a_agent_id] = (card, sha, version_id) + agents.append( + DiscoveredAgent( + agent_id=a2a_agent_id, + version_id=version_id, + source=source if source in {"hosted", "external"} else "hosted", + agent_card=card, + card_sha256=sha, + callable=True, + route_kind="hosted_gateway" if source == "hosted" else "external_public", + ) + ) + return SpaceAgentPage(agents=agents) + + async def _resolve_card(self, target_agent_id: str) -> tuple[AgentCard, str, str]: + cached = self._cards_by_agent.get(target_agent_id) + if cached is not None: + return cached + await self.list_space_agents(os.getenv("KSADK_A2A_SPACE_ID", ""), page_size=100) + cached = self._cards_by_agent.get(target_agent_id) + if cached is None: + raise A2AControlPlaneError( + code=404, + message=f"A2A agent {target_agent_id} not discoverable", + error_code="A2A_AGENT_NOT_FOUND", + action="PrepareA2ACall", # type: ignore[arg-type] + ) + return cached + + async def _direct_prepared( + self, + *, + target_agent_id: str, + version_id: str, + card: AgentCard, + card_sha256: str, + ) -> PreparedA2AOperation: + interfaces = list(card.supported_interfaces or []) + interface = interfaces[0] if interfaces else None + if interface is None or not interface.url: + raise A2AControlPlaneError( + code=502, + message="discovered agent card has no callable interface url", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + action="PrepareA2ACall", # type: ignore[arg-type] + ) + route = A2ARoute( + kind="hosted_gateway", + interface=A2ARouteInterface( + url=str(interface.url), + protocol_binding=str(interface.protocol_binding or "JSONRPC"), + protocol_version=str(interface.protocol_version or "1.0"), + ), + ) + return PreparedA2AOperation( + platform_task_id=f"a2a-task-{uuid.uuid4().hex}", + target=A2ATarget( + agent_id=target_agent_id, + version_id=version_id, + card_sha256=card_sha256, + ), + route=route, + call_permit="", + call_permit_expires_at="", + ) + + async def prepare_call( + self, + *, + space_id: str, + target_agent_id: str, + expected_version_id: str | None, + message_id: str, + message_sha256: str, + idempotency_token: str, + ) -> PreparedA2AOperation: + _ = (space_id, expected_version_id, message_id, message_sha256, idempotency_token) + card, sha, version_id = await self._resolve_card(target_agent_id) + return await self._direct_prepared( + target_agent_id=target_agent_id, + version_id=version_id, + card=card, + card_sha256=sha, + ) + + async def prepare_task_operation( + self, + *, + platform_task_id: str, + operation: A2AOperation, + message_id: str | None = None, + message_sha256: str | None = None, + idempotency_token: str | None = None, + agent_id: str | None = None, + ) -> PreparedA2AOperation: + _ = (operation, message_id, message_sha256, idempotency_token) + target_id = agent_id or platform_task_id + card, sha, version_id = await self._resolve_card(target_id) + return await self._direct_prepared( + target_agent_id=target_id, + version_id=version_id, + card=card, + card_sha256=sha, + ) + + async def bind_remote_task( + self, + *, + platform_task_id: str, + remote_task_id: str, + remote_context_id: str | None, + observed_at: str, + ) -> dict[str, Any]: + _ = (platform_task_id, remote_context_id, observed_at) + return {"RemoteTaskId": remote_task_id, "Bound": True} + + async def append_task_events( + self, + *, + platform_task_id: str, + events: list[dict[str, Any]], + ) -> dict[str, Any]: + _ = (platform_task_id, events) + return {"Appended": len(events)} + + async def resolve_credential( + self, + *, + platform_task_id: str, + credential_handle: str | None, + call_permit: str, + ) -> CredentialInjection: + _ = (platform_task_id, credential_handle, call_permit) + return CredentialInjection() + + def gateway_token(self) -> str: + return self._service_token + + def _credential_string_map(value: Any, *, field_name: str) -> dict[str, str]: if value is None: return {} @@ -654,8 +883,16 @@ def _agent_card_from_wire(payload: Any) -> AgentCard: message="AgentCard must be an object", error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", ) + # 兼容 0.3 card:顶层 url/preferredTransport 折叠进 supportedInterfaces[0]。 + if not payload.get("supportedInterfaces") and payload.get("url"): + payload = dict(payload) + payload["supportedInterfaces"] = [{ + "url": payload["url"], + "protocolBinding": payload.get("preferredTransport", "JSONRPC"), + "protocolVersion": payload.get("protocolVersion", "0.3"), + }] try: - return ParseDict(payload, AgentCard()) + return ParseDict(payload, AgentCard(), ignore_unknown_fields=True) except (ParseError, TypeError, ValueError) as exc: raise A2AControlPlaneError( code=502, @@ -798,6 +1035,7 @@ def _prepared_operation_from_wire(data: dict[str, Any]) -> PreparedA2AOperation: "A2AInternalAction", "A2A_INTERNAL_ACTIONS", "A2A_INTERNAL_PATH_PREFIX", + "A2AAgentCardClient", "A2AControlPlane", "A2AControlPlaneError", "A2AOperation", diff --git a/ksadk/a2a/executor.py b/ksadk/a2a/executor.py index 61eb7222..fec0d362 100644 --- a/ksadk/a2a/executor.py +++ b/ksadk/a2a/executor.py @@ -1,4 +1,4 @@ -"""A2ARuntimeExecutor — 把 ksadk runner 桥接进 A2A 请求生命周期 (goal-05)。 +"""A2ARuntimeExecutor — 把 RuntimeAdapter 桥接进 A2A 请求生命周期。 契约 §7.2:A2A ``context_id`` ↔ Runtime ``session_id``;``canceled`` ↔ ``RuntimeAdapter.cancel(invocation_id)``。executor 不在此自造 cancel,而是委托 @@ -136,7 +136,7 @@ async def _enqueue_initial_task(context: RequestContext, event_queue: EventQueue class _InputRequired(Exception): - """runner 请求用户输入的信号(内部用于跳出 execute,task 停 input-required)。""" + """Runtime 请求用户输入的信号(内部用于跳出 execute,task 停 input-required)。""" class _RunCanceled(Exception): @@ -144,23 +144,20 @@ class _RunCanceled(Exception): class A2ARuntimeExecutor(AgentExecutor): - """在 A2A 请求生命周期内执行 ksadk runner。 + """在 A2A 请求生命周期内执行 RuntimeAdapter。 - - sync: ``runner.invoke``。 - - streaming: ``runner.stream``,逐 chunk 发 artifact。 - - cancel: 委托 ``task_adapter.cancel_task``(内部走 RuntimeAdapter.cancel)。 + start/stream/resume/cancel 全部委托 ``A2ARuntimeTaskAdapter``;协议层不再 + 保留 Runner fallback,因此所有入口共享同一 RuntimeEvent 合同。 """ def __init__( self, - runner: Any, - task_adapter: Any = None, - prefer_stream: bool = True, + task_adapter: Any, include_reasoning: bool = False, ) -> None: - self.runner = runner + if task_adapter is None: + raise TypeError("task_adapter is required") self.task_adapter = task_adapter - self.prefer_stream = prefer_stream self.include_reasoning = include_reasoning async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: @@ -179,13 +176,12 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non == TaskState.TASK_STATE_INPUT_REQUIRED ) interaction_response: Any = None - if self.task_adapter is not None: - # Third-party/local adapters written before durable context mapping - # do not necessarily provide this optional lifecycle hook. - prepare_context = getattr(self.task_adapter, "prepare_context", None) - if callable(prepare_context): - await prepare_context(context) - if is_resume and self.task_adapter is not None: + # Third-party/local adapters written before durable context mapping do not + # necessarily provide this optional lifecycle hook. + prepare_context = getattr(self.task_adapter, "prepare_context", None) + if callable(prepare_context): + await prepare_context(context) + if is_resume: interaction_response = self.task_adapter.answer_from_context(context) # Invalid resume tokens/decisions are request errors. Validate before emitting # working so the durable Task remains input-required and retryable. @@ -206,25 +202,20 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non TaskState.TASK_STATE_WORKING, metadata=dict(ADK_V2_INTEGRATION_METADATA), ) - runner_input = self._build_runner_input(context) if is_resume: - if self.task_adapter is None: - raise RuntimeError("runtime task adapter is required to resume A2A task") handle = await self.task_adapter.resume_task( context.task_id or "", context, answer=interaction_response, ) output = await self._run_runtime(context, updater, handle) - elif self.task_adapter is not None: + else: handle = await self.task_adapter.start_task( task_id=str(context.task_id or ""), context=context, - input_data=runner_input.get("input"), + input_data=context.get_user_input(), ) output = await self._run_runtime(context, updater, handle) - else: - output = await self._run_runner(context, updater, runner_input) # completed 携带全文消息:非流式消费端与 text.completed 投影(§ event_adapter # message_to_event final)依赖它拿最终结果;流式消费端的重复由 adk_runner # 在 handoff 分支对"增量累积"去重解决(不在此处删消息)。 @@ -259,8 +250,6 @@ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None getattr(current_task, "context_id", "") or context.context_id or "unknown-context" ), ) - if self.task_adapter is None: - raise TaskNotCancelableError(message="runtime task adapter is not configured") result = await self.task_adapter.cancel_task(context.task_id or "", context) # 只有底层真的接受了取消(已中断活跃 turn,或已登记 pending cancel)才把 # 协议 Task 置 canceled;其他结果如实拒绝,包括未来新增的 UNSUPPORTED。 @@ -281,90 +270,6 @@ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None if inspect.isawaitable(result): await result - async def _run_runner( - self, context: RequestContext, updater: TaskUpdater, runner_input: dict[str, Any] - ) -> str: - stream = getattr(self.runner, "stream", None) - if self.prefer_stream and callable(stream): - return await self._run_streaming(context, updater, stream, runner_input) - - result = await self.runner.invoke(runner_input) - text = self._coerce_text(result) - if text: - await updater.add_artifact( - parts=[Part(text=text)], - artifact_id=f"{context.task_id}-response", - name="response", - last_chunk=True, - ) - return text - - async def _run_streaming( - self, - context: RequestContext, - updater: TaskUpdater, - stream: Any, - runner_input: dict[str, Any], - ) -> str: - output_text = "" - artifacts = _ArtifactStreamEmitter(updater, str(context.task_id)) - - async for chunk in stream(runner_input): - chunk_type = chunk.get("type") if isinstance(chunk, dict) else None - if chunk_type == "input_required": - raise RuntimeError( - "runner emitted input_required without a RuntimeAdapter execution path" - ) - if chunk_type == "final": - final_text = ( - self._coerce_text(chunk.get("output")) if isinstance(chunk, dict) else "" - ) - if not final_text: - continue - if not output_text: - output_text = final_text - await artifacts.push("text", final_text) - elif final_text.startswith(output_text): - suffix = final_text[len(output_text) :] - output_text = final_text - if suffix: - await artifacts.push("text", suffix) - continue - else: - output_text = final_text - await artifacts.push("text", final_text, replace_snapshot=True) - continue - - text = self._coerce_text(chunk) - if not text: - continue - if chunk_type == "thinking": - if self.include_reasoning: - await artifacts.push("thinking", text) - continue - replace = bool(isinstance(chunk, dict) and chunk.get("replace")) - output_text = text if replace else output_text + text - await artifacts.push("text", text, replace_snapshot=replace) - - await artifacts.close() - return output_text - - def _build_runner_input(self, context: RequestContext) -> dict[str, Any]: - metadata = dict(getattr(context, "metadata", None) or {}) - state = metadata.get("state", {}) - if not isinstance(state, dict): - state = {} - # §7.2: A2A context_id ↔ Runtime session_id。 - return { - "input": context.get_user_input(), - "task_id": context.task_id, - "context_id": context.context_id, - "session_id": context.context_id, - "state": dict(state), - "branch": metadata.get("branch", ""), - "metadata": metadata, - } - async def _run_runtime( self, context: RequestContext, diff --git a/ksadk/a2a/routes.py b/ksadk/a2a/routes.py index c4ab5a8d..7b995de2 100644 --- a/ksadk/a2a/routes.py +++ b/ksadk/a2a/routes.py @@ -19,7 +19,7 @@ def create_runtime_app(config): from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Optional, Sequence +from typing import Optional, Sequence from a2a.server.routes import add_a2a_routes_to_fastapi from a2a.server.tasks import TaskStore @@ -57,7 +57,6 @@ class A2AConfig: def add_a2a_protocol_routes( app: FastAPI, - runner: Any, config: A2AConfig, *, task_adapter: A2ARuntimeTaskAdapter, @@ -85,19 +84,27 @@ def add_a2a_protocol_routes( create_table=config.create_table, ) server = A2AProtocolServer( - runner, agent_card=card, task_store=store, task_adapter=task_adapter, context_builder=context_builder, - prefer_stream=config.prefer_stream, include_reasoning=config.include_reasoning, ) + rest = server.rest_routes() + # a2a-sdk REST routes 附带贪婪 Mount('/{tenant}')(多租户 catch-all), + # 它会拦截 /v1/responses、/agentengine/api/v1/*、/chat 等单段前缀路径导致 404。 + # 只丢弃贪婪 Mount('/{tenant}');保留其他 Mount(多租户显式路径如 /tenant-a/a2a/v1/*)。 + from starlette.routing import Mount as _Mount + + rest = [ + route for route in rest + if not (isinstance(route, _Mount) and getattr(route, "path", "") == "/{tenant}") + ] add_a2a_routes_to_fastapi( app, agent_card_routes=server.agent_card_routes(), jsonrpc_routes=server.jsonrpc_routes(), - rest_routes=server.rest_routes(), + rest_routes=rest, ) return server diff --git a/ksadk/a2a/server.py b/ksadk/a2a/server.py index 7af79c5b..eb36a1c6 100644 --- a/ksadk/a2a/server.py +++ b/ksadk/a2a/server.py @@ -59,37 +59,30 @@ async def build( class A2AProtocolServer: - """把一个 ksadk runner 暴露为 A2A 协议数据面。 + """把一个 RuntimeAdapter 暴露为 A2A 协议数据面。 参数: - runner: 当前 runtime 的 runner。 agent_card: 符合 wire 1.0 的 AgentCard(``ksadk.a2a.card.build_agent_card``)。 task_store: durable ``DatabaseTaskStore``(``ksadk.a2a.task_store``)。 task_adapter: 可选 ``A2ARuntimeTaskAdapter``(提供则 cancel 走 RuntimeAdapter.cancel)。 - prefer_stream: 是否优先用 runner.stream(默认 True)。 include_reasoning: 是否把 reasoning 输出为 ``adk_thought`` artifact。 """ def __init__( self, - runner: Any, *, agent_card: AgentCard, task_store: TaskStore, task_adapter: A2ARuntimeTaskAdapter, context_builder: A2AOwnerContextBuilder | None = None, - prefer_stream: bool = True, include_reasoning: bool = False, ) -> None: - self.runner = runner self.agent_card = agent_card self.task_store = task_store self.task_adapter = task_adapter self.context_builder = context_builder or A2AOwnerContextBuilder() self.executor = A2ARuntimeExecutor( - runner, task_adapter=task_adapter, - prefer_stream=prefer_stream, include_reasoning=include_reasoning, ) self.request_handler = DefaultRequestHandler( diff --git a/ksadk/a2a/service_env.py b/ksadk/a2a/service_env.py new file mode 100644 index 00000000..5e77d59f --- /dev/null +++ b/ksadk/a2a/service_env.py @@ -0,0 +1,33 @@ +"""A2A Space 服务地址与凭证解析(对齐 ksadk/skills 的 KOP pull 模式)。 + +runtime 通过 KOP 公网网关访问 agentengine-server 的对外 A2A Action +(ListAToASpaceAgents,返回里已含完整投影后 AgentCard),与 Skill Space +走同一套 AICP 连接解析:默认 aicp.api.ksyun.com,可被 KSADK_A2A_SERVICE_URL 覆盖。 +runtime 拿到 card 后直接用 card.url 调用,不需要 gateway 域名。 +""" + +from __future__ import annotations + +import os + +from ksadk.common.aicp_env import resolve_aicp_connection + +ENV_A2A_SERVICE_URL = "KSADK_A2A_SERVICE_URL" +ENV_A2A_SERVICE_TOKEN = "KSADK_A2A_SERVICE_TOKEN" + + +def resolve_a2a_service_url() -> str: + """返回 A2A 控制面对外服务 origin(不含 path)。 + + 优先 KSADK_A2A_SERVICE_URL;否则用 AICP 连接解析(aicp.api.ksyun.com)。 + """ + explicit = os.environ.get(ENV_A2A_SERVICE_URL, "").strip() + if explicit: + return explicit + connection = resolve_aicp_connection("KSADK_A2A_SERVICE") + return f"{connection['scheme']}://{connection['endpoint']}".rstrip("/") + + +def resolve_a2a_service_token() -> str: + """返回 A2A 控制面服务 token(平台注入)。""" + return os.environ.get(ENV_A2A_SERVICE_TOKEN, "").strip() diff --git a/ksadk/a2a/space_client.py b/ksadk/a2a/space_client.py index 53b039e2..9b52997e 100644 --- a/ksadk/a2a/space_client.py +++ b/ksadk/a2a/space_client.py @@ -32,12 +32,11 @@ from google.protobuf.json_format import MessageToDict, ParseDict from ksadk.a2a.control_plane import ( - ENV_A2A_CONTROL_PLANE_URL, + A2AAgentCardClient, A2AControlPlane, A2ARouteInterface, CredentialInjection, DiscoveredAgent, - InternalA2AControlPlaneClient, PreparedA2AOperation, SpaceAgentPage, ) @@ -54,6 +53,7 @@ logger = logging.getLogger(__name__) +ENV_A2A_SPACE_ID = "KSADK_A2A_SPACE_ID" ENV_A2A_SPACE_IDS = "KSADK_A2A_SPACE_IDS" ENV_A2A_ENABLE_PUBLIC_EGRESS = "KSADK_A2A_ENABLE_PUBLIC_EGRESS" @@ -63,6 +63,14 @@ MAX_A2A_MESSAGE_ID_LENGTH = 128 +def _require_opaque_space_id(value: str, *, field_name: str) -> str: + """Validate a server-issued opaque Space ID without assuming a prefix.""" + normalized = str(value or "").strip() + if not normalized or len(normalized) > 256 or any(char.isspace() for char in normalized): + raise ValueError(f"{field_name} must be a non-empty opaque resource ID") + return normalized + + @dataclass(frozen=True) class A2APlatformTask: """AgentEngine task locator with an optional latest remote A2A snapshot.""" @@ -111,7 +119,7 @@ def __init__( event_outbox: A2ATaskEventOutbox | None = None, event_dispatcher: A2ATaskEventDispatcher | None = None, ) -> None: - require_a2a_resource_id(space_id, "a2a-space-", field_name="space_id") + space_id = _require_opaque_space_id(space_id, field_name="space_id") if external_transport is not None and not isinstance( external_transport, A2AExternalTransport ): @@ -148,19 +156,19 @@ def from_env( event_outbox: A2ATaskEventOutbox | None = None, event_dispatcher: A2ATaskEventDispatcher | None = None, ) -> "A2ASpaceClient": - selected_space_id = str(space_id or "").strip() + selected_space_id = str( + space_id or os.getenv(ENV_A2A_SPACE_ID) or "" + ).strip() if selected_space_id: - require_a2a_resource_id( - selected_space_id, - "a2a-space-", - field_name="space_id", + selected_space_id = _require_opaque_space_id( + selected_space_id, field_name="space_id" ) else: raw_space_ids = str(os.getenv(ENV_A2A_SPACE_IDS) or "").strip() if not raw_space_ids: raise ValueError( - f"missing {ENV_A2A_SPACE_IDS}; pass space_id or add the Runtime Agent " - "to an A2A Space first" + f"missing {ENV_A2A_SPACE_ID} and {ENV_A2A_SPACE_IDS}; pass space_id " + "or add the Runtime Agent to an A2A Space first" ) try: configured_space_ids = json.loads(raw_space_ids) @@ -175,10 +183,8 @@ def from_env( if not isinstance(configured_space_id, str): raise ValueError(f"{ENV_A2A_SPACE_IDS}[{index}] must be an A2A Space ID string") normalized = configured_space_id.strip() - require_a2a_resource_id( - normalized, - "a2a-space-", - field_name=f"{ENV_A2A_SPACE_IDS}[{index}]", + normalized = _require_opaque_space_id( + normalized, field_name=f"{ENV_A2A_SPACE_IDS}[{index}]" ) normalized_space_ids.append(normalized) if len(set(normalized_space_ids)) != len(normalized_space_ids): @@ -189,11 +195,18 @@ def from_env( ) selected_space_id = normalized_space_ids[0] if backend is None: - control_plane_url = str(os.getenv(ENV_A2A_CONTROL_PLANE_URL) or "").strip() - if not control_plane_url: - raise ValueError(f"missing {ENV_A2A_CONTROL_PLANE_URL}") - backend = InternalA2AControlPlaneClient( - control_plane_url, + from ksadk.a2a.service_env import ( + resolve_a2a_service_token, + resolve_a2a_service_url, + ) + service_url = resolve_a2a_service_url() + if not service_url: + raise ValueError( + "A2A service url not configured: set KSADK_A2A_SERVICE_URL or AICP env" + ) + backend = A2AAgentCardClient( + service_url, + service_token=resolve_a2a_service_token(), httpx_client=httpx_client, ) if egress_enabled is None: @@ -354,7 +367,9 @@ async def _send_prepared_message( async for response in client.send_message(request, context=context): response_task = _present_message_field(response, "task") if response_task is not None and str(getattr(response_task, "id", None) or ""): - first_task = first_task or response_task + # 跟踪最新 task 状态:每个带 task 的 response 都更新 first_task, + # 使 send_message 返回的是最终状态(如 COMPLETED)而非首个 SUBMITTED。 + first_task = response_task observed_task_id = str(response_task.id) observed_context_id = str(response_task.context_id or "") or None if remote_task_id is None: @@ -464,9 +479,11 @@ async def cancel( async def get_task(self, task_id: str) -> A2APlatformTask: require_a2a_resource_id(task_id, "a2a-task-", field_name="task_id") + cached_agent = self._agents_by_task.get(task_id) prepared = await self._backend.prepare_task_operation( platform_task_id=task_id, operation="get_task", + agent_id=getattr(cached_agent, "agent_id", None), ) self._validate_prepared_ids(prepared) remote_task_ref = self._require_remote_task(prepared) @@ -627,10 +644,12 @@ async def _operation_client( headers: dict[str, str] async with AsyncExitStack() as exit_stack: if prepared.route.kind == "hosted_gateway": + token = self._backend.gateway_token() headers = { - "Authorization": f"Bearer {self._backend.gateway_token()}", "X-AgentEngine-A2A-Permit": prepared.call_permit, } + if token: + headers["Authorization"] = f"Bearer {token}" http = self._httpx_client else: if prepared.route.kind == "external_public" and not self._egress_enabled: @@ -670,6 +689,7 @@ async def _operation_client( route_card = self._card_for_route(agent.agent_card, route) owned_http = None if http is None: + # 外部调用方需自行传入已配置 verify 的 httpx_client。 owned_http = httpx.AsyncClient(trust_env=False) http = owned_http client = await create_client( @@ -1095,6 +1115,7 @@ async def _record_agent_for_task(self, task_id: str, agent: DiscoveredAgent) -> "A2ASpaceClient", "DiscoveredAgent", "ENV_A2A_ENABLE_PUBLIC_EGRESS", + "ENV_A2A_SPACE_ID", "ENV_A2A_SPACE_IDS", "ERR_PUBLIC_EGRESS_DISABLED", "SpaceAgentPage", diff --git a/ksadk/a2a_card.py b/ksadk/a2a_card.py new file mode 100644 index 00000000..14f22310 --- /dev/null +++ b/ksadk/a2a_card.py @@ -0,0 +1,101 @@ +"""Lightweight A2A AgentCard construction shared by discovery and data-plane code. + +This module deliberately lives outside :mod:`ksadk.a2a`: importing that package +initializes the full v2 protocol stack, including optional database adapters. +Managed v1 discovery must be able to start with only the core FastAPI A2A +dependency installed. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill + +A2A_PROTOCOL_VERSION = "1.0" +JSONRPC_PATH = "/a2a/jsonrpc" +REST_PATH_PREFIX = "/a2a/v1" + +_DEFAULT_IO_MODES = ["text/plain"] + + +def build_agent_card( + *, + name: str, + base_url: str, + description: str = "", + version: str = "1.0.0", + skills: Sequence[str | AgentSkill] | None = None, + streaming: bool = True, + provider: dict[str, str] | None = None, +) -> AgentCard: + """Build a wire-1.0 AgentCard with supported interfaces.""" + base = base_url.rstrip("/") + supported_interfaces = [ + AgentInterface( + url=f"{base}{JSONRPC_PATH}", + protocol_binding="JSONRPC", + protocol_version=A2A_PROTOCOL_VERSION, + ), + AgentInterface( + url=f"{base}{REST_PATH_PREFIX}", + protocol_binding="HTTP+JSON", + protocol_version=A2A_PROTOCOL_VERSION, + ), + ] + + card_kwargs: dict = { + "name": name, + "description": description or f"{name} agent powered by ksadk", + "version": version, + "supported_interfaces": supported_interfaces, + "capabilities": AgentCapabilities(streaming=streaming, push_notifications=False), + "default_input_modes": list(_DEFAULT_IO_MODES), + "default_output_modes": list(_DEFAULT_IO_MODES), + "skills": _build_skills(skills), + } + if provider: + from a2a.types import AgentProvider + + card_kwargs["provider"] = AgentProvider( + organization=provider.get("organization", ""), + url=provider.get("url", ""), + ) + return AgentCard(**card_kwargs) + + +def _build_skills(skills: Sequence[str | AgentSkill] | None) -> list[AgentSkill]: + if not skills: + return [ + AgentSkill( + id="general", + name="General", + description="General purpose agent powered by ksadk", + tags=["general"], + ) + ] + result: list[AgentSkill] = [] + for skill in skills: + if isinstance(skill, AgentSkill): + result.append(skill) + continue + if not isinstance(skill, str) or not skill.strip(): + raise TypeError("AgentCard skills must be non-empty strings or AgentSkill objects") + skill_id = skill.strip() + result.append( + AgentSkill( + id=skill_id, + name=skill_id.replace("_", " ").title(), + description=f"Skill: {skill_id}", + tags=[skill_id], + ) + ) + return result + + +__all__ = [ + "A2A_PROTOCOL_VERSION", + "JSONRPC_PATH", + "REST_PATH_PREFIX", + "build_agent_card", +] diff --git a/ksadk/agui/agent.py b/ksadk/agui/agent.py index bd143b61..8d9c563a 100644 --- a/ksadk/agui/agent.py +++ b/ksadk/agui/agent.py @@ -47,9 +47,10 @@ ResumePayload, ResumeTarget, RunHandle, - RuntimeAdapter, + RuntimeLaunchContext, StartRequest, ) +from ksadk.runtime.executor import RuntimeExecutor EventStoreFactory = Callable[[], Any] SessionServiceFactory = Callable[[], Any] @@ -85,7 +86,8 @@ class _ThreadRun: @dataclass class _SharedRuntime: - adapter: RuntimeAdapter + executor: RuntimeExecutor + launch_context: RuntimeLaunchContext event_store_factory: Optional[EventStoreFactory] session_service_factory: Optional[SessionServiceFactory] threads: dict[str, _ThreadRun] = field(default_factory=dict) @@ -118,20 +120,27 @@ def __init__( self, *, name: str, - adapter: RuntimeAdapter, + executor: RuntimeExecutor | None = None, + launch_context: RuntimeLaunchContext | None = None, event_store_factory: Optional[EventStoreFactory] = None, session_service_factory: Optional[SessionServiceFactory] = None, _shared: Optional[_SharedRuntime] = None, ) -> None: self.name = name - self._shared = _shared or _SharedRuntime( - adapter, - event_store_factory, - session_service_factory, - ) + if _shared is not None: + self._shared = _shared + else: + if executor is None or launch_context is None: + raise ValueError("AG-UI requires RuntimeExecutor and RuntimeLaunchContext") + self._shared = _SharedRuntime( + executor, + launch_context, + event_store_factory, + session_service_factory, + ) def clone(self) -> "KsadkAGUIAgent": - return type(self)(name=self.name, adapter=self._shared.adapter, _shared=self._shared) + return type(self)(name=self.name, _shared=self._shared) async def run(self, input: RunAgentInput) -> AsyncIterator[BaseEvent]: wire = _WireState( @@ -160,7 +169,7 @@ async def run(self, input: RunAgentInput) -> AsyncIterator[BaseEvent]: return run.active = True - async for runtime_event in self._shared.adapter.stream(run.handle): + async for runtime_event in self._shared.executor.stream(run.handle): persisted = await self._persist(self._event_for_persistence(runtime_event, run)) async for event in self._project(persisted, wire, run): yield event @@ -230,7 +239,10 @@ async def _resolve_run(self, input: RunAgentInput) -> tuple[_ThreadRun, bool]: config=request_config, metadata={"invocation_id": input.run_id, "transport": "ag-ui"}, ) - handle = await self._shared.adapter.start(request) + handle = await self._shared.executor.start( + self._shared.launch_context, + request, + ) await self._persist_user_input(input, request, handle) run = _ThreadRun(handle=handle) self._shared.threads[input.thread_id] = run @@ -287,7 +299,7 @@ async def _resume_locked(self, input: RunAgentInput) -> tuple[_ThreadRun, bool]: for interrupt_id, fingerprint in fingerprints.items(): self._shared.consumed_resumes[(input.thread_id, interrupt_id)] = fingerprint - resumed = await self._shared.adapter.resume( + resumed = await self._shared.executor.resume( current.handle, ResumeTarget(kind="checkpoint_id", id=next(iter(checkpoint_ids))), ResumePayload( @@ -692,8 +704,11 @@ async def _restore_durable_run( pending[interrupt.id] = _PendingInterrupt(interrupt, checkpoint_id) if not pending: return None, False - if not self._shared.adapter.is_handle_attached(handle): - handle = await self._shared.adapter.attach(handle) + if not self._shared.executor.is_attached(handle): + handle = await self._shared.executor.attach( + self._shared.launch_context, + handle, + ) return _ThreadRun(handle=handle, interrupted=True, pending=pending), False async def _durable_events(self, session_id: str) -> list[RuntimeEvent]: @@ -757,14 +772,14 @@ def _json_safe(value: Any) -> Any: async def _close_thread(self, thread_id: str, run: _ThreadRun) -> None: try: - await self._shared.adapter.close(run.handle) + await self._shared.executor.close(run.handle) finally: async with self._shared.lock: if self._shared.threads.get(thread_id) is run: self._shared.threads.pop(thread_id, None) async def _cancel_and_close(self, thread_id: str, run: _ThreadRun) -> CancelResult: - result = await self._shared.adapter.cancel(run.handle) + result = await self._shared.executor.cancel(run.handle) if result in { CancelResult.INTERRUPTED_ACTIVE_TURN, CancelResult.PENDING_CANCEL_RECORDED, diff --git a/ksadk/agui/config.py b/ksadk/agui/config.py index d3386e8a..82a9d4bc 100644 --- a/ksadk/agui/config.py +++ b/ksadk/agui/config.py @@ -46,15 +46,15 @@ def agui_dependencies_available() -> bool: return not agui_dependency_errors() -def default_agui_config(runner: Any) -> AGUIConfig: - """Build the production AG-UI config without loading the user's agent. +def agui_config_for_detection(detection: Any) -> AGUIConfig: + """Build the production AG-UI config from a detected framework. Goal 24's first production vertical is LangGraph. An optional AG-UI install therefore enables the endpoint automatically only for a detected LangGraph - runner; explicit ``RuntimeAppConfig.agui`` remains available to harnesses and - future framework adapters. + runtime; explicit ``RuntimeAppConfig.agui`` remains available to harnesses and + future framework adapters. CLI bootstraps have a detection result before a + runner is constructed, so they use this same policy directly. """ - detection = getattr(runner, "detection_result", None) framework_type = getattr(detection, "type", None) runtime_type = str(getattr(framework_type, "value", framework_type) or "").lower() agent_name = str(getattr(detection, "name", None) or "ksadk") @@ -65,6 +65,11 @@ def default_agui_config(runner: Any) -> AGUIConfig: ) +def default_agui_config(runner: Any) -> AGUIConfig: + """Build the production AG-UI config without loading the user's agent.""" + return agui_config_for_detection(getattr(runner, "detection_result", None)) + + def require_agui_dependencies() -> None: errors = agui_dependency_errors() if errors: @@ -81,6 +86,7 @@ def require_agui_dependencies() -> None: "COPILOTKIT_VERSION", "agui_dependencies_available", "agui_dependency_errors", + "agui_config_for_detection", "default_agui_config", "require_agui_dependencies", ] diff --git a/ksadk/agui/routes.py b/ksadk/agui/routes.py index 792d7f3d..d020eb2d 100644 --- a/ksadk/agui/routes.py +++ b/ksadk/agui/routes.py @@ -9,17 +9,8 @@ from ksadk.agui.agent import KsadkAGUIAgent from ksadk.agui.config import AGUIConfig, require_agui_dependencies -from ksadk.runners.base_runner import BaseRunner -from ksadk.runtime.framework_adapters import ADKRuntimeAdapter, LangGraphRuntimeAdapter -from ksadk.runtime.runner_adapter import RunnerRuntimeAdapter - - -def build_runtime_adapter(runner: BaseRunner, *, runtime_type: str): - if runtime_type == "langgraph": - return LangGraphRuntimeAdapter(runner) - if runtime_type == "adk": - return ADKRuntimeAdapter(runner) - return RunnerRuntimeAdapter(runner, runtime_type=runtime_type) +from ksadk.runtime.adapter import RuntimeLaunchContext +from ksadk.runtime.executor import RuntimeExecutor def _fastapi_endpoint_helper() -> Callable[..., Any]: @@ -33,7 +24,8 @@ def _fastapi_endpoint_helper() -> Callable[..., Any]: def add_ksadk_agui_endpoint( app: FastAPI, - runner: BaseRunner, + executor: RuntimeExecutor, + launch_context: RuntimeLaunchContext, config: AGUIConfig, *, event_store_factory=None, @@ -41,10 +33,10 @@ def add_ksadk_agui_endpoint( ) -> KsadkAGUIAgent: require_agui_dependencies() - adapter = build_runtime_adapter(runner, runtime_type=config.runtime_type) agent = KsadkAGUIAgent( name=config.agent_name, - adapter=adapter, + executor=executor, + launch_context=launch_context, event_store_factory=event_store_factory, session_service_factory=session_service_factory, ) @@ -52,4 +44,4 @@ def add_ksadk_agui_endpoint( return agent -__all__ = ["add_ksadk_agui_endpoint", "build_runtime_adapter"] +__all__ = ["add_ksadk_agui_endpoint"] diff --git a/ksadk/api/client.py b/ksadk/api/client.py index fc83d77f..d82b5c4b 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -20,6 +20,7 @@ import requests import urllib3 +from ksadk.api.environment import environment_variables_payload from ksadk.common.auth import AWSV4Auth logger = logging.getLogger(__name__) @@ -1380,14 +1381,11 @@ async def create_agent(self, data: Dict[str, Any]) -> Dict[str, Any]: image_credential=ic, ) - env_vars = [] envs = data.get("env_vars") or data.get("environment_variables") if envs: - if isinstance(envs, dict): - for k, v in envs.items(): - env_vars.append({"Key": k, "Value": str(v), "IsSensitive": False}) - elif isinstance(envs, list): - env_vars = envs + env_vars = environment_variables_payload(envs) + else: + env_vars = [] advanced = { "EnableObservability": True, @@ -1681,13 +1679,7 @@ async def update_agent(self, agent_id: str, data: Dict[str, Any]) -> Dict[str, A envs = data.get("env_vars") or data.get("environment_variables") if envs: - env_vars = [] - if isinstance(envs, dict): - for k, v in envs.items(): - env_vars.append({"Key": k, "Value": str(v), "IsSensitive": False}) - elif isinstance(envs, list): - env_vars = envs - params["EnvironmentVariables"] = env_vars + params["EnvironmentVariables"] = environment_variables_payload(envs) network_payload = self._normalize_network_payload(data.get("network")) if network_payload: diff --git a/ksadk/api/environment.py b/ksadk/api/environment.py new file mode 100644 index 00000000..082602a4 --- /dev/null +++ b/ksadk/api/environment.py @@ -0,0 +1,27 @@ +"""Small control-plane payload helpers shared by Agent API operations.""" + +from __future__ import annotations + +from typing import Any + +from ksadk.configs.env_registry import is_sensitive_env_var + + +def environment_variables_payload(value: Any) -> list[dict[str, Any]]: + """Normalize environment variables while preserving their sensitivity flag.""" + + if isinstance(value, dict): + return [ + { + "Key": str(key), + "Value": str(item), + "IsSensitive": is_sensitive_env_var(str(key)), + } + for key, item in value.items() + ] + if isinstance(value, list): + return value + return [] + + +__all__ = ["environment_variables_payload"] diff --git a/ksadk/builders/code_builder.py b/ksadk/builders/code_builder.py index 232ea357..ba8f02f4 100644 --- a/ksadk/builders/code_builder.py +++ b/ksadk/builders/code_builder.py @@ -30,6 +30,7 @@ from ksadk.builders.base import BaseBuilder, BuildResult from ksadk.builders.framework_requirements import ( FASTAPI_REQUIREMENT, + STARLETTE_REQUIREMENT, code_requirements_for_framework, ) from ksadk.builders.requirements_utils import ( @@ -111,10 +112,14 @@ class CodeBuilder(BaseBuilder): } BUNDLED_KSADK_CORE_RUNTIME_REQUIREMENTS = ( "a2a-sdk>=0.3.22", + "culsans>=0.11.0", "httpx-sse>=0.4.0", "sse-starlette>=2.1.0", "python-multipart>=0.0.9,<1.0.0", "requests>=2.28.0", + "sqlalchemy>=2.0.0", + "aiosqlite>=0.19.0", + "greenlet>=3.0.0", "requests-aws4auth>=1.2.0", "kingsoftcloud-sdk-python>=1.5.8.94", "cryptography>=44.0.0", @@ -796,17 +801,18 @@ def _get_base_requirements(self, detection_result) -> List[str]: deps = [ # Core FASTAPI_REQUIREMENT, + STARLETTE_REQUIREMENT, "uvicorn>=0.23.0", "python-dotenv>=1.0.0", "pydantic>=2.0.0", "pyyaml>=6.0.0", "httpx>=0.24.0", - # Tracing + # Tracing — OTLP 双写收敛:只用 OTel + openinference 自动插桩, + # 不再安装 Langfuse SDK(callback 路径已删除,Langfuse 通过标准 OTLP 接收)。 "opentelemetry-api>=1.37.0", "opentelemetry-sdk>=1.37.0", "opentelemetry-exporter-otlp>=1.37.0", "openinference-instrumentation-langchain>=0.1.0", - "langfuse>=2.0.0", ] framework = detection_result.type.value @@ -1808,15 +1814,11 @@ def _generate_entrypoint(self, detection_result) -> str: # 打印关键环境变量 (隐藏敏感信息) env_keys = [ "AGENT_RUNTIME_NAME", "AGENT_RUNTIME_ID", "ACCOUNT_ID", "PORT", - "LANGFUSE_BASE_URL", "LANGFUSE_HOST", "LANGFUSE_PUBLIC_KEY", - "LANGFUSE_SECRET_KEY", "LANGFUSE_USE_CALLBACK", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXPORTER_OTLP_TRACES_HEADERS", - "OTEL_SERVICE_NAME", "CLOUD_MONITOR_APP_KEY", - "CLOUD_MONITOR_OTLP_ENABLED", "CLOUD_MONITOR_OTLP_ENDPOINT", - "CLOUD_MONITOR_OTLP_TRACES_ENDPOINT", "CLOUD_MONITOR_OTLP_HEADERS", - "CLOUD_MONITOR_LANGFUSE_HOST", "CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY", - "CLOUD_MONITOR_LANGFUSE_SECRET_KEY", "CLOUD_MONITOR_LANGFUSE_ENABLED", + "OTEL_SERVICE_NAME", + "CLOUD_MONITOR_OTLP_ENDPOINT", "CLOUD_MONITOR_OTLP_TRACES_ENDPOINT", + "CLOUD_MONITOR_OTLP_HEADERS", "CLOUD_MONITOR_OTLP_TRACES_HEADERS", "LANGCHAIN_TRACING_V2", "MODEL_NAME", ] for key in env_keys: @@ -1833,15 +1835,9 @@ def _generate_entrypoint(self, detection_result) -> str: from ksadk.configs import setup_environment setup_environment(Path(CODE_ROOT)) -try: - from ksadk.runners.patch_langchain import apply_patch as apply_langchain_patch - apply_langchain_patch() -except ImportError: - pass - -from ksadk.runners import create_runner from ksadk.detection import DetectionResult, FrameworkType -from ksadk.server import app, set_runner +from ksadk.runtime import RuntimeExecutor, RuntimeLaunchContext, build_default_runtime_registry +from ksadk.server import RuntimeAppConfig, configure_runtime_app, create_runtime_app import uvicorn # 检测结果 (构建时固化) @@ -1851,57 +1847,84 @@ def _generate_entrypoint(self, detection_result) -> str: entry_point="{detection_result.entry_point}", package_path=os.path.join(CODE_ROOT, "{package_name}"), agent_variable="{detection_result.agent_variable}", - runner_class="{getattr(detection_result, 'runner_class', '')}" + runner_class="{getattr(detection_result, "runner_class", "")}" ) logger.info(f"框架: {{detection_result.name}}") logger.info(f"入口: {{detection_result.entry_point}}") -# 初始化 Tracing (如果配置了 Langfuse、标准 OTLP HTTP 或 CloudMonitor OTLP) +# 初始化 Tracing (如果配置了标准 OTLP HTTP 或 CloudMonitor OTLP) has_otlp = bool( os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") ) has_cloud_monitor_otlp = bool( - os.environ.get("CLOUD_MONITOR_APP_KEY") + os.environ.get("CLOUD_MONITOR_OTLP_TRACES_HEADERS") + or os.environ.get("CLOUD_MONITOR_OTLP_HEADERS") or os.environ.get("CLOUD_MONITOR_OTLP_ENDPOINT") or os.environ.get("CLOUD_MONITOR_OTLP_TRACES_ENDPOINT") + or os.environ.get("CLOUD_MONITOR_APP_KEY") ) -has_cloud_monitor_langfuse = bool( - os.environ.get("CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY") - or os.environ.get("CLOUD_MONITOR_LANGFUSE_SECRET_KEY") - or os.environ.get("CLOUD_MONITOR_LANGFUSE_HOST") -) -if ( - os.environ.get("LANGFUSE_PUBLIC_KEY") - or has_otlp - or has_cloud_monitor_otlp - or has_cloud_monitor_langfuse -): +if has_otlp or has_cloud_monitor_otlp: try: from ksadk.tracing import setup_tracing - use_callback_only = ( - os.environ.get("LANGFUSE_USE_CALLBACK", "").strip().lower() - in ("1", "true", "yes", "on") - ) - - setup_tracing(use_callback_only=use_callback_only) + setup_tracing() logger.info( f"Tracing 已启用 (OTLP={{has_otlp}}, " - f"CloudMonitorOTLP={{has_cloud_monitor_otlp}}, " - f"CloudMonitorLangfuse={{has_cloud_monitor_langfuse}}, " - f"CallbackOnly={{use_callback_only}})" + f"CloudMonitorOTLP={{has_cloud_monitor_otlp}})" ) except Exception as e: logger.warning(f"Tracing 初始化失败: {{e}}") -# 创建 Runner 并加载 Agent -logger.info("正在加载 Agent...") -runner = create_runner(detection_result, CODE_ROOT) -runner.load_agent() -set_runner(runner, loaded=True) -logger.info("Agent 加载成功!") +# 只装配统一 RuntimeAdapter 执行链;具体 Adapter 在请求开始时由 Registry 创建。 +runtime_context = RuntimeLaunchContext( + runtime_type=detection_result.type.value, + project_dir=Path(CODE_ROOT), + detection=detection_result, + config=dict(getattr(detection_result, "raw_config", None) or {{}}), +) +# managed A2A:KSADK_A2A_RUNTIME_ID 非空时挂 discovery card + 完整数据面 route。 +_a2a_config = None +_a2a_adapter = None +if os.environ.get("KSADK_A2A_RUNTIME_ID", "").strip(): + from ksadk.managed_a2a_card import build_managed_a2a_card_if_configured + + _managed_a2a_card = build_managed_a2a_card_if_configured() + try: + from ksadk.a2a.routes import A2AConfig + from ksadk.runtime.factory import create_runtime_adapter + + _a2a_adapter = create_runtime_adapter(runtime_context) + _base = ( + os.environ.get("KSADK_A2A_INTERNAL_BASE_URL", "").strip() + or "http://localhost:8080" + ) + _a2a_config = A2AConfig( + enabled=True, + base_url=_base, + agent_name=( + os.environ.get("KSADK_A2A_AGENT_NAME", "").strip() + or os.environ.get("KSADK_A2A_RUNTIME_ID", "").strip() + ), + streaming=True, + task_store_dsn="sqlite+aiosqlite:///.agentengine/a2a_tasks.db", + ) + except Exception as _e: + logger.warning(f"managed A2A 数据面装配失败,回退 discovery-only: {{_e}}") + _a2a_config = None + _a2a_adapter = None +app = create_runtime_app( + RuntimeAppConfig( + runtime_type=detection_result.type.value, + runtime_executor=RuntimeExecutor(build_default_runtime_registry()), + launch_context=runtime_context, + a2a=_a2a_config or _managed_a2a_card, + a2a_runtime_adapter=_a2a_adapter, + ), + configure_runtime_app, +) +logger.info("RuntimeAdapter 执行链装配成功!") if __name__ == "__main__": port = int(os.environ.get("PORT", 8080)) diff --git a/ksadk/builders/container_builder.py b/ksadk/builders/container_builder.py index 7c70a032..9c0c40f6 100644 --- a/ksadk/builders/container_builder.py +++ b/ksadk/builders/container_builder.py @@ -404,12 +404,12 @@ def _generate_requirements(self, detection_result, project_path: Optional[Path] "pydantic>=2.0.0", "pyyaml>=6.0.0", "httpx>=0.24.0", - # Tracing + # Tracing — OTLP 双写收敛:只用 OTel + openinference 自动插桩, + # 不再安装 Langfuse SDK(callback 路径已删除,Langfuse 通过标准 OTLP 接收)。 "opentelemetry-api>=1.37.0", "opentelemetry-sdk>=1.37.0", "opentelemetry-exporter-otlp>=1.37.0", "openinference-instrumentation-langchain>=0.1.0", - "langfuse>=2.0.0", ] framework = detection_result.type.value @@ -489,15 +489,11 @@ def _generate_entrypoint(self, detection_result, package_name: str) -> str: env_keys = [ "AGENT_RUNTIME_NAME", "AGENT_RUNTIME_ID", "ACCOUNT_ID", "PORT", - "LANGFUSE_BASE_URL", "LANGFUSE_HOST", "LANGFUSE_PUBLIC_KEY", - "LANGFUSE_SECRET_KEY", "LANGFUSE_USE_CALLBACK", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXPORTER_OTLP_TRACES_HEADERS", - "OTEL_SERVICE_NAME", "CLOUD_MONITOR_APP_KEY", - "CLOUD_MONITOR_OTLP_ENABLED", "CLOUD_MONITOR_OTLP_ENDPOINT", - "CLOUD_MONITOR_OTLP_TRACES_ENDPOINT", "CLOUD_MONITOR_OTLP_HEADERS", - "CLOUD_MONITOR_LANGFUSE_HOST", "CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY", - "CLOUD_MONITOR_LANGFUSE_SECRET_KEY", "CLOUD_MONITOR_LANGFUSE_ENABLED", + "OTEL_SERVICE_NAME", + "CLOUD_MONITOR_OTLP_ENDPOINT", "CLOUD_MONITOR_OTLP_TRACES_ENDPOINT", + "CLOUD_MONITOR_OTLP_HEADERS", "CLOUD_MONITOR_OTLP_TRACES_HEADERS", "LANGCHAIN_TRACING_V2", "MODEL_NAME", ] for key in env_keys: @@ -511,15 +507,9 @@ def _generate_entrypoint(self, detection_result, package_name: str) -> str: from ksadk.configs import setup_environment setup_environment(Path("/app")) -try: - from ksadk.runners.patch_langchain import apply_patch as apply_langchain_patch - apply_langchain_patch() -except ImportError: - pass - -from ksadk.runners import create_runner from ksadk.detection import DetectionResult, FrameworkType -from ksadk.server import app, set_runner +from ksadk.runtime import RuntimeExecutor, RuntimeLaunchContext, build_default_runtime_registry +from ksadk.server import RuntimeAppConfig, configure_runtime_app, create_runtime_app import uvicorn # 检测结果 (构建时固化) @@ -535,49 +525,79 @@ def _generate_entrypoint(self, detection_result, package_name: str) -> str: logger.info(f"框架: {{detection_result.name}}") logger.info(f"入口: {{detection_result.entry_point}}") -# 初始化 Tracing (如果配置了 Langfuse、标准 OTLP HTTP 或 CloudMonitor OTLP) +# 初始化 Tracing (如果配置了标准 OTLP HTTP 或 CloudMonitor OTLP) has_otlp = bool( os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") ) has_cloud_monitor_otlp = bool( - os.environ.get("CLOUD_MONITOR_APP_KEY") + os.environ.get("CLOUD_MONITOR_OTLP_TRACES_HEADERS") + or os.environ.get("CLOUD_MONITOR_OTLP_HEADERS") or os.environ.get("CLOUD_MONITOR_OTLP_ENDPOINT") or os.environ.get("CLOUD_MONITOR_OTLP_TRACES_ENDPOINT") + or os.environ.get("CLOUD_MONITOR_APP_KEY") ) -has_cloud_monitor_langfuse = bool( - os.environ.get("CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY") - or os.environ.get("CLOUD_MONITOR_LANGFUSE_SECRET_KEY") - or os.environ.get("CLOUD_MONITOR_LANGFUSE_HOST") -) -if ( - os.environ.get("LANGFUSE_PUBLIC_KEY") - or has_otlp - or has_cloud_monitor_otlp - or has_cloud_monitor_langfuse -): +if has_otlp or has_cloud_monitor_otlp: try: from ksadk.tracing import setup_tracing - use_callback_only = ( - os.environ.get("LANGFUSE_USE_CALLBACK", "").strip().lower() - in ("1", "true", "yes", "on") - ) - setup_tracing(use_callback_only=use_callback_only) + setup_tracing() logger.info( f"Tracing 已启用 (OTLP={{has_otlp}}, " - f"CloudMonitorOTLP={{has_cloud_monitor_otlp}}, " - f"CloudMonitorLangfuse={{has_cloud_monitor_langfuse}}, " - f"CallbackOnly={{use_callback_only}})" + f"CloudMonitorOTLP={{has_cloud_monitor_otlp}})" ) except Exception as e: logger.warning(f"Tracing 初始化失败: {{e}}") -# 创建 Runner 并加载 Agent -logger.info("正在加载 Agent...") -runner = create_runner(detection_result, "/app") -runner.load_agent() -set_runner(runner, loaded=True) -logger.info("Agent 加载成功!") +# 只装配统一 RuntimeAdapter 执行链;具体 Adapter 在请求开始时由 Registry 创建。 +runtime_context = RuntimeLaunchContext( + runtime_type=detection_result.type.value, + project_dir=Path("/app"), + detection=detection_result, + config=dict(getattr(detection_result, "raw_config", None) or {{}}), +) +# managed A2A:KSADK_A2A_RUNTIME_ID 非空时挂 discovery card + 完整数据面 route。 +# discovery card 让 server 探测;数据面 route 让 gateway 转发的 JSON-RPC/REST +# 能真正落到本 runtime 的 A2A 协议端点(路线 C 直连)。 +_a2a_config = None +_a2a_adapter = None +if os.environ.get("KSADK_A2A_RUNTIME_ID", "").strip(): + from ksadk.managed_a2a_card import build_managed_a2a_card_if_configured + + _managed_a2a_card = build_managed_a2a_card_if_configured() + try: + from ksadk.a2a.routes import A2AConfig + from ksadk.runtime.factory import create_runtime_adapter + + _a2a_adapter = create_runtime_adapter(runtime_context) + _base = ( + os.environ.get("KSADK_A2A_INTERNAL_BASE_URL", "").strip() + or "http://localhost:8080" + ) + _a2a_config = A2AConfig( + enabled=True, + base_url=_base, + agent_name=( + os.environ.get("KSADK_A2A_AGENT_NAME", "").strip() + or os.environ.get("KSADK_A2A_RUNTIME_ID", "").strip() + ), + streaming=True, + task_store_dsn="sqlite+aiosqlite:///.agentengine/a2a_tasks.db", + ) + except Exception as _e: + logger.warning(f"managed A2A 数据面装配失败,回退 discovery-only: {{_e}}") + _a2a_config = None + _a2a_adapter = None +app = create_runtime_app( + RuntimeAppConfig( + runtime_type=detection_result.type.value, + runtime_executor=RuntimeExecutor(build_default_runtime_registry()), + launch_context=runtime_context, + a2a=_a2a_config or _managed_a2a_card, + a2a_runtime_adapter=_a2a_adapter, + ), + configure_runtime_app, +) +logger.info("RuntimeAdapter 执行链装配成功!") if __name__ == "__main__": port = int(os.environ.get("PORT", 8080)) diff --git a/ksadk/builders/framework_requirements.py b/ksadk/builders/framework_requirements.py index 2442c8d9..8a9a50db 100644 --- a/ksadk/builders/framework_requirements.py +++ b/ksadk/builders/framework_requirements.py @@ -4,7 +4,8 @@ from typing import Iterable -FASTAPI_REQUIREMENT = "fastapi>=0.100.0,<1.0.0" +FASTAPI_REQUIREMENT = "fastapi>=0.136.0,<0.137.0" +STARLETTE_REQUIREMENT = "starlette>=1.0,<1.4" ADK_REQUIREMENTS = ( # goal-00: 与 ksadk 自身 adk extra 对齐,允许 1.34.x 与 2.x diff --git a/ksadk/builders/ks3_uploader.py b/ksadk/builders/ks3_uploader.py index 25913fdf..7c082acb 100644 --- a/ksadk/builders/ks3_uploader.py +++ b/ksadk/builders/ks3_uploader.py @@ -10,7 +10,7 @@ from typing import Optional import click -from ks3.upload import UploadTask +from ks3.upload import UploadTask # type: ignore[import-untyped] from ksadk.common.constants import get_ks3_endpoints @@ -258,7 +258,7 @@ def _resumable_record_path(file_path: Path, object_key: str) -> Path: return record_dir / f"{safe_key}.ks3resume" def _upload_via_host(self, file_path: Path, object_key: str, host: str) -> bool: - from ks3.connection import Connection + from ks3.connection import Connection # type: ignore[import-untyped] ak = os.environ.get("KSYUN_ACCESS_KEY") or os.environ.get("KS3_ACCESS_KEY") sk = os.environ.get("KSYUN_SECRET_KEY") or os.environ.get("KS3_SECRET_KEY") diff --git a/ksadk/builders/managed_runtime_builder.py b/ksadk/builders/managed_runtime_builder.py index df927002..39cc6e00 100644 --- a/ksadk/builders/managed_runtime_builder.py +++ b/ksadk/builders/managed_runtime_builder.py @@ -20,10 +20,39 @@ "artifact_type", "runtime", "model", + "models", "prompt", + "skills", + "mcp_servers", + "sandbox", + "approval_mode", ) +class _RuntimeManifestDumper(yaml.SafeDumper): + """Keep multi-line prompts readable while preserving deterministic bytes.""" + + +def _represent_manifest_string(dumper: yaml.SafeDumper, value: str): + style = "|" if "\n" in value else None + return dumper.represent_scalar("tag:yaml.org,2002:str", value, style=style) + + +_RuntimeManifestDumper.add_representer(str, _represent_manifest_string) + + +def serialize_managed_runtime_manifest(manifest: dict[str, Any]) -> bytes: + """Serialize the canonical ManagedRuntime manifest used by every client.""" + + return yaml.dump( + manifest, + Dumper=_RuntimeManifestDumper, + allow_unicode=True, + sort_keys=False, + default_flow_style=False, + ).encode("utf-8") + + class ManagedRuntimeBuilder(BaseBuilder): """Build a deterministic YAML-only deployment artifact. @@ -44,7 +73,10 @@ def __init__( self.build_dir = self.project_dir / ".agentengine" / "managed_runtime" def build(self) -> BuildResult: - config = self._load_config() + # An explicitly supplied snapshot is authoritative. Studio can keep + # more than one Agent manifest in a workspace, so falling back to the + # root agentengine.yaml here would silently build the wrong Agent. + config = dict(self.config) if self.config else self._load_config() error = self._validate_config(config) if error: return BuildResult(success=False, error_message=error) @@ -63,11 +95,7 @@ def build(self) -> BuildResult: runtime_name = str(runtime.get("name") or config.get("framework") or "").strip().lower() runtime = {"name": runtime_name, "version": version} manifest = self._normalized_manifest(config, runtime) - manifest_bytes = yaml.safe_dump( - manifest, - allow_unicode=True, - sort_keys=False, - ).encode("utf-8") + manifest_bytes = serialize_managed_runtime_manifest(manifest) manifest_sha256 = hashlib.sha256(manifest_bytes).hexdigest() lock = { "schema_version": RUNTIME_MANIFEST_SCHEMA, diff --git a/ksadk/cli/__init__.py b/ksadk/cli/__init__.py index 7f8f90bb..ca88a118 100644 --- a/ksadk/cli/__init__.py +++ b/ksadk/cli/__init__.py @@ -67,6 +67,7 @@ def _gradient_line(text: str, colors: list) -> str: "completion", "dashboard", "deploy", + "eval", "files", "init", "hermes", @@ -74,6 +75,7 @@ def _gradient_line(text: str, colors: list) -> str: "mcp", "openclaw", "run", + "studio", "version", "web", } @@ -87,6 +89,7 @@ def _gradient_line(text: str, colors: list) -> str: "completion": "Shell 补全管理", "dashboard": "打开云端 Agent Dashboard", "deploy": "部署到云端", + "eval": "评测本地、A2A 或 Codex Agent", "files": "管理 workspace 文件", "hermes": "Hermes Agent 资源管理", "init": "创建新项目", @@ -94,6 +97,7 @@ def _gradient_line(text: str, colors: list) -> str: "mcp": "MCP 资源管理", "openclaw": "OpenClaw 资源管理", "run": "运行 Agent", + "studio": "启动本地 Agent 构建控制台", "version": "Agent 版本管理", "web": "本地调试 Agent Invoke UI", "config": "项目配置与模型设置", @@ -173,6 +177,8 @@ def format_help(self, ctx, formatter): _write_colored_help_row(formatter, "agentengine init", "初始化项目") _write_colored_help_row(formatter, "agentengine run", "运行 API Server") _write_colored_help_row(formatter, "agentengine web", "本地调试 Agent Invoke UI") + _write_colored_help_row(formatter, "agentengine studio", "本地 Agent 构建控制台") + _write_colored_help_row(formatter, "agentengine eval", "评测本地、A2A 或 Codex Agent") # 云端部署 formatter.write(click.style(" 🚀 云端部署:\n\n", fg="blue", bold=True)) @@ -336,6 +342,8 @@ def _register_commands(): _register_optional_command(cli, "ksadk.cli.cmd_config", "config") _register_optional_command(cli, "ksadk.cli.cmd_model", "model") _register_optional_command(cli, "ksadk.cli.cmd_build", "build") + _register_optional_command(cli, "ksadk.cli.cmd_studio", "studio") + _register_optional_command(cli, "ksadk.cli.cmd_eval", "eval") _register_optional_command(cli, "ksadk.cli.cmd_launch", "launch") _register_optional_command(cli, "ksadk.cli.cmd_agent", "agent") _register_optional_command(cli, "ksadk.cli.cmd_status", "status") diff --git a/ksadk/cli/cmd_a2a.py b/ksadk/cli/cmd_a2a.py index 4b3c879c..16e3baaf 100644 --- a/ksadk/cli/cmd_a2a.py +++ b/ksadk/cli/cmd_a2a.py @@ -32,10 +32,8 @@ from ksadk.cli.local_runtime import reexec_with_project_venv_if_needed from ksadk.cli.resource_common import CONTEXT_SETTINGS from ksadk.detection import FrameworkDetector -from ksadk.runners.factory import create_runner -from ksadk.runtime.adapter import RuntimeAdapter -from ksadk.runtime.framework_adapters import ADKRuntimeAdapter, LangGraphRuntimeAdapter -from ksadk.runtime.runner_adapter import RunnerRuntimeAdapter +from ksadk.runtime.factory import create_runtime_adapter +from ksadk.runtime.launch import RuntimeLaunchContext _HELP = dict(help_option_names=["-h", "--help"]) _DEFAULT_TASK_STORE_DSN = "sqlite+aiosqlite:///.agentengine/a2a_tasks.db" @@ -121,7 +119,10 @@ def serve( command_args.append("--no-trace") reexec_with_project_venv_if_needed(agent_path, command_args) task_store_dsn = _resolve_task_store_dsn(task_store_dsn, agent_path) - detection_result, runner = _load_runner(agent_path, no_trace=no_trace) + detection_result, runtime_adapter = _load_runtime_adapter( + agent_path, + no_trace=no_trace, + ) from fastapi import FastAPI app = FastAPI(title=f"ksadk A2A: {detection_result.name}") @@ -135,12 +136,10 @@ def serve( include_reasoning=include_reasoning, ) runtime_type = detection_result.type.value - runtime_adapter = _select_runtime_adapter(runtime_type, runner) from ksadk.a2a.task_adapter import A2ARuntimeTaskAdapter add_a2a_protocol_routes( app, - runner, config, task_adapter=A2ARuntimeTaskAdapter(runtime_adapter, runtime_type=runtime_type), ) @@ -212,7 +211,8 @@ def _space_client(space_id: str | None = None) -> A2ASpaceClient: except ValueError as exc: raise click.ClickException( f"A2A Space 未配置:{exc}。" - "请传 --space-id,或由 AgentEngine 部署注入 KSADK_A2A_SPACE_IDS;同时需要" + "请传 --space-id,或由 AgentEngine 部署注入 KSADK_A2A_SPACE_ID" + "(兼容 KSADK_A2A_SPACE_IDS);同时需要" "KSADK_A2A_CONTROL_PLANE_URL 和 audience workload token。" ) from exc @@ -312,40 +312,27 @@ def _detect_project(agent_path: Path, *, configure_environment: bool = True): return result -def _load_runner(agent_path: Path, *, no_trace: bool): +def _load_runtime_adapter(agent_path: Path, *, no_trace: bool): detection_result = _detect_project(agent_path) + runtime_type = detection_result.type.value if not no_trace: - _setup_tracing(detection_result.type.value) - runner = create_runner(detection_result, str(agent_path)) - runner.load_agent() - return detection_result, runner - - -def _select_runtime_adapter(runtime_type: str, runner) -> RuntimeAdapter: - # langchain create_agent 产物是 LangGraph CompiledStateGraph, - # 与 langgraph 共用 time-travel resume。 - if runtime_type in ("langgraph", "langchain"): - return LangGraphRuntimeAdapter(runner) - if runtime_type == "adk": - return ADKRuntimeAdapter(runner) - return RunnerRuntimeAdapter(runner, runtime_type=runtime_type) + _setup_tracing(runtime_type) + context = RuntimeLaunchContext( + runtime_type=runtime_type, + project_dir=agent_path, + detection=detection_result, + config=dict(getattr(detection_result, "raw_config", None) or {}), + ) + return detection_result, create_runtime_adapter(context) def _setup_tracing(framework_type: str) -> None: try: - import os from ksadk.tracing import setup_tracing - use_callback_only = os.getenv("LANGFUSE_USE_CALLBACK", "").strip().lower() in ( - "1", - "true", - "yes", - "on", - ) setup_tracing( enable_inmemory=True, - use_callback_only=use_callback_only, ) except Exception: return diff --git a/ksadk/cli/cmd_create.py b/ksadk/cli/cmd_create.py index 1cef7ea0..b3a8b9b7 100644 --- a/ksadk/cli/cmd_create.py +++ b/ksadk/cli/cmd_create.py @@ -976,7 +976,7 @@ def _generate_deepagents_service_adapter_content(analysis: dict) -> str: runnable_build = """ try: runnable_module = importlib.import_module(RUNNABLE_MODULE, __package__) deep_agent_runnable = getattr(runnable_module, "DeepAgentRunnable") - return deep_agent_runnable(agent, _NullLangfuseManager()) + return deep_agent_runnable(agent, _NullObservabilityManager()) except Exception: return None """ @@ -992,20 +992,11 @@ def _generate_deepagents_service_adapter_content(analysis: dict) -> str: import importlib from typing import Any -try: - from langchain_core.callbacks import BaseCallbackHandler -except Exception: # pragma: no cover - dependency may be absent during static checks - BaseCallbackHandler = object - INIT_MODULE = "{init_module}" {runnable_module_constant} -class _NoopCallbackHandler(BaseCallbackHandler): - pass - - -class _NullLangfuseManager: - callback_handler = _NoopCallbackHandler() +class _NullObservabilityManager: + callback_handler = None class AgentEngineDeepAgentsServiceAdapter: @@ -1591,16 +1582,16 @@ def _write_hermes_project_config(project_path: Path, package_name: str) -> None: def _write_codex_project_config(project_path: Path, package_name: str) -> None: """Write the minimal codex runtime project:yaml 驱动,无 package/agent.py。 - codex 的 agent 逻辑由 prompt 承载(CodexRunner.load_agent 是 no-op),所以只生成 + codex 的 agent 逻辑由 prompt 承载,所以只生成 canonical agentengine.yaml(framework: codex,model+prompt)+ requirements + - README。model/prompt 经 CodexRunner 传入 codex thread(开发者指令)。 + README。model/prompt 经 CodexRuntimeAdapter 传入 codex thread(开发者指令)。 """ (project_path / "agentengine.yaml").write_text( f"""# AgentEngine codex runtime 项目配置 name: {package_name} version: "1.0.0" -# 框架:codex(开发态 CodexRunner → 部署态 codex-runtime) +# 框架:codex(本地和部署统一使用 CodexRuntimeAdapter) framework: codex artifact_type: ManagedRuntime @@ -1609,7 +1600,7 @@ def _write_codex_project_config(project_path: Path, package_name: str) -> None: runtime: name: codex -# codex 的模型与开发者指令(CodexRunner 读取并传入 codex thread) +# codex 的模型与开发者指令(CodexRuntimeAdapter 传入 codex thread) model: glm-5.2 prompt: | 你是 codex 编码助手。简洁回答,能跑命令验证就跑(shell 工具),中文回复。 @@ -1639,7 +1630,9 @@ def _write_codex_project_config(project_path: Path, package_name: str) -> None: - codex 自动探测模型协议:OpenAI 官方直连,星流 chat 模型自动启用转换代理。 - `agentengine build .` 只生成 YAML manifest bundle,不打包本机 Codex 二进制。若 `runtime.version` 未指定,build 需要连接到已配置 Runtime catalog;离线构建请显式锁定版本。 -- 部署时服务端解析 ManagedRuntime 版本和 Linux 镜像 digest。 +- 部署时服务端解析 ManagedRuntime 版本和 Linux 镜像 digest。云端必须已发布 Runtime + catalog、对应镜像和内联 manifest 控制面;未发布时请继续使用本地 `ksadk web`,不要把 + `runtime.version` 随意改成开发机版本,KsADK 不回退为 Code 部署。 """, encoding="utf-8-sig", ) @@ -1885,8 +1878,6 @@ def create(project_name: str, framework: str, from_agent_path: str): elif framework == "codex": env_content = _generate_codex_env_content(global_env) else: - langfuse_url = global_env.get("LANGFUSE_BASE_URL", "") - env_content = """# ====================== # 模型配置 (必填, 可以从星流平台获取https://ksp.console.ksyun.com/#/apiKey) # ====================== @@ -1909,13 +1900,9 @@ def create(project_name: str, framework: str, from_agent_path: str): # 可观测性 (可选) # ====================== """ - env_content += "# LANGFUSE_PUBLIC_KEY=pk-xxx\n" - env_content += "# LANGFUSE_SECRET_KEY=sk-xxx\n" - - if langfuse_url: - env_content += f"LANGFUSE_BASE_URL={langfuse_url}\n" - else: - env_content += "# LANGFUSE_BASE_URL=https://cloud.langfuse.com\n" + env_content += "# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf\n" + env_content += "# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://collector.example.com/v1/traces\n" + env_content += "# OTEL_EXPORTER_OTLP_TRACES_HEADERS=Authorization=Bearer%20placeholder\n" env_content += """ # ====================== diff --git a/ksadk/cli/cmd_deploy.py b/ksadk/cli/cmd_deploy.py index 79f9af6e..b238ed0c 100644 --- a/ksadk/cli/cmd_deploy.py +++ b/ksadk/cli/cmd_deploy.py @@ -80,6 +80,14 @@ help="部署目标 (default: serverless)", ) @click.option("--name", "-n", help="部署名称") +@click.option( + "--agent-id", + default=None, + help=( + "指定要更新的已有 Agent ID;当前凭证有权限时会自动回填 " + ".agentengine.state 并走热更新(用于本地状态丢失后重新关联)" + ), +) @click.option( "--region", "-r", @@ -136,6 +144,7 @@ def deploy( agent_dir: str, target: str, name: str, + agent_id: str | None, region: str, account_id: str, artifact_type: str, @@ -240,6 +249,7 @@ def render_deploy_dry_run(exc: DryRunExit) -> None: env_file=env_file, repackage=repackage, dry_run_context=dry_run_context, + agent_id=agent_id, ), dry_run=dry_run, on_dry_run=render_deploy_dry_run, @@ -315,6 +325,7 @@ async def _deploy_async( dry_run_context: dict[str, object] | None = None, *, repackage: bool = False, + agent_id: str | None = None, ): """异步部署流程""" from ksadk.deployment import DeploymentManager, DeployTarget @@ -431,6 +442,7 @@ async def _deploy_async( "env_vars": explicit_env_vars, "runtime_name": resolved_runtime.name if resolved_runtime else "", "runtime_version": resolved_runtime.version if resolved_runtime else "", + "agent_id": agent_id, }, ) @@ -543,7 +555,12 @@ async def _deploy_async( print_info("Dry Run: 仅生成本地构建计划,不执行真实构建/上传") if resolved_artifact_plan.will_build: - print_rule(f"Step 2/{total_steps} 构建与上传") + build_step = ( + "构建运行时声明" + if effective_artifact_type == "ManagedRuntime" + else "构建与上传" + ) + print_rule(f"Step 2/{total_steps} {build_step}") try: with capture_standard_output(): package_info = await provider.build(package_info, deploy_target) diff --git a/ksadk/cli/cmd_eval.py b/ksadk/cli/cmd_eval.py new file mode 100644 index 00000000..095b8716 --- /dev/null +++ b/ksadk/cli/cmd_eval.py @@ -0,0 +1,386 @@ +"""agentengine eval - stable CLI shell for local evaluation.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import click +from rich.markup import escape +from rich.padding import Padding +from rich.table import Table + +from ksadk.cli.ui import ( + configure_ui_runtime, + emit_json, + get_console, + is_json_output, + new_table, + print_info, + print_kv, + print_success, + print_title, + status_rich_style, +) +from ksadk.evaluation import ( + EvaluationConfig, + EvaluationExecutionError, + EvaluationNotImplementedError, + EvaluationRequest, + TargetRef, + execute_evaluation, + load_evalset, +) +from ksadk.evaluation.contracts import ( + DataPolicy, + EvalRunReport, + EvalRunStatus, + EvalRunSummary, + MetricResult, + MetricStatus, + TargetKind, + TargetRunStatus, +) +from ksadk.evaluation.evalset import EvalSetParseError +from ksadk.evaluation.evaluators import DEFAULT_EVALUATORS, SUPPORTED_EVALUATORS +from ksadk.evaluation.storage import EvaluationStorage + +_DEFAULT_EVALUATORS = tuple(DEFAULT_EVALUATORS) +_DATA_POLICIES = tuple(policy.value for policy in DataPolicy) + + +class EvaluationCliError(click.ClickException): + exit_code = 2 + + +@click.command(context_settings=dict(help_option_names=["-h", "--help"])) +@click.option( + "--evalset-file", + required=True, + type=click.Path(exists=True, dir_okay=False, path_type=Path), + help="本地 EvalSet YAML/JSON 文件", +) +@click.option( + "--agent-dir", + type=click.Path(exists=True, file_okay=False, path_type=Path), + help="本地 Agent 项目目录", +) +@click.option("--a2a-url", type=str, help="远端 A2A Agent Card URL") +@click.option( + "--codex-worktree", + type=click.Path(exists=True, file_okay=False, path_type=Path), + help="Codex 评测使用的 Git worktree", +) +@click.option("--entrypoint", type=str, help="本地 Agent 入口;缺省时由 target adapter 检测") +@click.option("--credential-ref", type=str, help="A2A 鉴权引用;不会写入凭据值") +@click.option("--codex-profile", type=str, help="Codex 受信评测 profile 名称") +@click.option( + "--evaluator", + "evaluators", + multiple=True, + type=click.Choice(SUPPORTED_EVALUATORS), + help="启用评估器;可重复指定", +) +@click.option("--judge-model", type=str, help="LLM Judge 模型名") +@click.option("--judge-api-base", type=str, help="LLM Judge OpenAI 兼容 API 地址") +@click.option( + "--judge-api-key-env", + default="KSADK_EVAL_JUDGE_API_KEY", + show_default=True, + type=str, + help="保存 Judge API Key 的环境变量名", +) +@click.option( + "--timeout-seconds", + default=120, + show_default=True, + type=click.IntRange(1, 3600), + help="单个 Case 超时时间(秒)", +) +@click.option("--fail-fast", is_flag=True, help="首个失败 Case 后停止") +@click.option( + "--data-policy", + type=click.Choice(_DATA_POLICIES), + default="local_only", + show_default=True, + help="评测数据策略", +) +@click.option( + "--report-dir", + type=click.Path(file_okay=False, path_type=Path), + help="报告目录;缺省为项目 .agentkit/evaluations", +) +@click.option("--validate-only", is_flag=True, help="只识别并校验 EvalSet 和 CLI 参数") +@click.option( + "--format", + "output_format", + type=click.Choice(["pretty", "json"]), + help="终端输出格式", +) +def eval( + evalset_file: Path, + agent_dir: Path | None, + a2a_url: str | None, + codex_worktree: Path | None, + entrypoint: str | None, + credential_ref: str | None, + codex_profile: str | None, + evaluators: tuple[str, ...], + judge_model: str | None, + judge_api_base: str | None, + judge_api_key_env: str, + timeout_seconds: int, + fail_fast: bool, + data_policy: str, + report_dir: Path | None, + validate_only: bool, + output_format: str | None, +) -> None: + """校验 EvalSet,并通过统一入口发起 Agent 评测。""" + + if output_format: + configure_ui_runtime(output_mode=output_format) + target = _target_ref( + agent_dir=agent_dir, + a2a_url=a2a_url, + codex_worktree=codex_worktree, + entrypoint=entrypoint, + credential_ref=credential_ref, + codex_profile=codex_profile, + ) + request = _build_request( + evalset_file=evalset_file, + target=target, + evaluators=evaluators, + judge_model=judge_model, + judge_api_base=judge_api_base, + judge_api_key_env=judge_api_key_env, + timeout_seconds=timeout_seconds, + fail_fast=fail_fast, + data_policy=data_policy, + report_dir=report_dir, + ) + if validate_only: + _render_validation(request) + return + + report = _execute_request(request) + exit_code = _report_exit_code(report) + _render_report(report, report_dir=request.report_dir) + if exit_code: + raise click.exceptions.Exit(exit_code) + + +def _build_request( + *, + evalset_file: Path, + target: TargetRef, + evaluators: tuple[str, ...], + judge_model: str | None, + judge_api_base: str | None, + judge_api_key_env: str, + timeout_seconds: int, + fail_fast: bool, + data_policy: str, + report_dir: Path | None, +) -> EvaluationRequest: + try: + evalset = load_evalset(evalset_file) + except EvalSetParseError as exc: + raise click.UsageError(f"{exc.code}: {exc}") from exc + + return EvaluationRequest( + evalset=evalset, + target=target, + config=EvaluationConfig( + timeout_seconds=timeout_seconds, + fail_fast=fail_fast, + evaluators=list(evaluators) or list(_DEFAULT_EVALUATORS), + data_policy=data_policy, + judge_model=judge_model, + judge_api_base=judge_api_base, + judge_api_key_env=judge_api_key_env, + ), + report_dir=str((report_dir or Path.cwd() / ".agentkit/evaluations").resolve()), + ) + + +def _execute_request(request: EvaluationRequest) -> EvalRunReport: + on_case_started = None + if not is_json_output(): + _render_start(request) + on_case_started = _render_case_started + try: + return asyncio.run(execute_evaluation(request, on_case_started=on_case_started)) + except (EvaluationNotImplementedError, EvaluationExecutionError) as exc: + raise EvaluationCliError(str(exc)) from exc + + +def _render_report(report: EvalRunReport, *, report_dir: str | None = None) -> None: + if is_json_output(): + emit_json(report.model_dump(mode="json", by_alias=True, exclude_none=True)) + else: + print_title("Agent 评测完成") + get_console().print("[title][i]评测摘要[/i][/]") + print_kv("运行状态", report.status.value) + print_kv("Run ID", report.spec.id) + if report_dir: + print_kv( + "报告文件", + str(EvaluationStorage(report_dir).report_path(report.spec.id)), + value_style="#58a6ff", + ) + _render_report_preview(report) + + + +def _render_start(request: EvaluationRequest) -> None: + print_info( + "开始评测:" + f"{request.evalset.name},{len(request.evalset.cases)} 个 Case," + f"Target: {request.target.kind.value}" + ) + + +def _render_case_started(case_id: str, index: int, total_cases: int) -> None: + print_info(f"[{index}/{total_cases}] 执行 Case: {case_id}") + + +def _render_report_preview(report: EvalRunReport) -> None: + _render_summary(report.summary) + _render_case_table(report) + remaining_cases = len(report.case_runs) - 5 + if remaining_cases > 0: + print_info(f"其余 {remaining_cases} 个 Case 已省略") + + +def _render_summary(summary: EvalRunSummary) -> None: + items = ( + ("总计", summary.total_cases, "white"), + ("通过", summary.passed_cases, "ok"), + ("失败", summary.failed_cases, "err"), + ("错误", summary.error_cases, "err"), + ("不可用", summary.unavailable_cases, "warn"), + ("取消", summary.cancelled_cases, "warn"), + ) + table = Table.grid(padding=(0, 2)) + for _label, _count, style in items: + table.add_column(justify="center", style=style, no_wrap=True) + table.add_row(*(label for label, _count, _style in items), style="muted") + table.add_row(*(f"[{style if count else 'muted'}]{count}[/]" for _label, count, style in items)) + print_info(" 结果统计") + get_console().print(Padding(table, (0, 0, 0, 4))) + + +def _render_case_table(report: EvalRunReport) -> None: + table = new_table(" 评测集列表") + table.add_column("Case", style="#58a6ff", no_wrap=True) + table.add_column("目标状态", no_wrap=True) + table.add_column("耗时", justify="right", no_wrap=True) + table.add_column("指标") + for case_run in report.case_runs[:5]: + target_status = case_run.target_run.status.value + table.add_row( + escape(case_run.case_id), + f"[{status_rich_style(target_status)}]{target_status}[/]", + _format_duration(case_run.target_run.duration_ms), + _metric_summary(case_run.metrics), + ) + get_console().print(table) + + +def _format_duration(duration_ms: int | None) -> str: + return f"{duration_ms} ms" if duration_ms is not None else "-" + + +def _metric_summary(metrics: list[MetricResult]) -> str: + if not metrics: + return "无指标" + counts = {status: sum(metric.status is status for metric in metrics) for status in MetricStatus} + return ",".join(f"{status.value} {count}" for status, count in counts.items() if count) + + +def _report_exit_code(report: EvalRunReport) -> int: + if report.status in { + EvalRunStatus.ERROR, + EvalRunStatus.CANCELLED, + EvalRunStatus.PENDING, + EvalRunStatus.RUNNING, + }: + return 2 + if any( + case_run.target_run.status is TargetRunStatus.UNAVAILABLE + or any( + metric.required and metric.status is MetricStatus.UNAVAILABLE + for metric in case_run.metrics + ) + for case_run in report.case_runs + ): + return 3 + return 1 if report.status is EvalRunStatus.FAILED else 0 + + +def _target_ref( + agent_dir: Path | None, + a2a_url: str | None, + codex_worktree: Path | None, + entrypoint: str | None, + credential_ref: str | None, + codex_profile: str | None, +) -> TargetRef: + targets = [agent_dir is not None, bool(a2a_url), codex_worktree is not None] + if sum(targets) != 1: + raise click.UsageError("--agent-dir、--a2a-url、--codex-worktree 必须且只能指定一个") + if credential_ref and not a2a_url: + raise click.UsageError("--credential-ref 只能与 --a2a-url 一起使用") + if entrypoint and not agent_dir: + raise click.UsageError("--entrypoint 只能与 --agent-dir 一起使用") + if codex_profile and not codex_worktree: + raise click.UsageError("--codex-profile 只能与 --codex-worktree 一起使用") + if agent_dir: + return TargetRef( + kind=TargetKind.LOCAL_SOURCE, + locator=str(agent_dir.resolve()), + entrypoint=entrypoint, + ) + if a2a_url: + return TargetRef( + kind=TargetKind.A2A, + locator=a2a_url, + credential_ref=credential_ref, + ) + assert codex_worktree is not None + return TargetRef( + kind=TargetKind.CODEX_WORKTREE, + locator=str(codex_worktree.resolve()), + profile=codex_profile, + ) + + +def _render_validation(request: EvaluationRequest) -> None: + payload = { + "valid": True, + "evalset": { + "name": request.evalset.name, + "sourceFormat": request.evalset.source_format, + "caseCount": len(request.evalset.cases), + "contentDigest": request.evalset.content_digest, + }, + "target": request.target.model_dump(mode="json", by_alias=True, exclude_none=True), + "config": request.config.model_dump(mode="json", by_alias=True), + "reportDir": request.report_dir, + } + if is_json_output(): + emit_json(payload) + return + print_title("评测配置校验") + print_kv("EvalSet", request.evalset.name) + print_kv("格式", request.evalset.source_format) + print_kv("Case 数量", str(len(request.evalset.cases))) + print_kv("内容摘要", request.evalset.content_digest) + print_kv("Target", f"{request.target.kind.value}: {request.target.locator}") + print_success("EvalSet 与 CLI 参数有效") + + +# Eval reports use --report-dir exclusively; do not inject the generic child --output. +eval.disable_global_output_option = True diff --git a/ksadk/cli/cmd_hermes.py b/ksadk/cli/cmd_hermes.py index 2c2f28cf..23b3d533 100644 --- a/ksadk/cli/cmd_hermes.py +++ b/ksadk/cli/cmd_hermes.py @@ -4,6 +4,7 @@ import asyncio import os +from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Optional, cast @@ -14,6 +15,12 @@ from ksadk.cli.agent_ref import merge_agent_inputs, resolve_agent_ref from ksadk.cli.cmd_dashboard import _open_dashboard from ksadk.cli.dry_run import dry_run_option, effective_dry_run, run_async_with_dry_run +from ksadk.cli.env_options import ( + apply_explicit_env_with_shell_priority, + env_options, + inject_env_to_environ, + resolve_runtime_env_overrides, +) from ksadk.cli.error_utils import remote_error, resolution_error from ksadk.cli.model_catalog import fetch_provider_model_metadata from ksadk.cli.network_options import build_network_payload, network_cli_kwargs, network_options @@ -46,6 +53,7 @@ from ksadk.cli.ui import ( output_option as cli_output_option, ) +from ksadk.configs.env_registry import is_sensitive_env_var from ksadk.deployment.agent_access import ( get_latest_agent_access, is_agent_not_found_error, @@ -54,7 +62,6 @@ from ksadk.deployment.state import clear_state, load_state, save_state from ksadk.hermes_terminal import ( run_hermes_terminal_session, - validate_hermes_exec_argv, validate_hermes_pairing_argv, ) from ksadk.model_policy import build_runtime_model_policy_env @@ -150,6 +157,7 @@ def _build_hermes_update_payload( "resources": payload["resources"], "scaling": payload["scaling"], "ui_config": payload["ui_config"], + "enable_observability": payload["enable_observability"], } if include_env: update_payload["env_vars"] = payload["env_vars"] @@ -165,19 +173,6 @@ def hermes(): """Hermes Agent 资源管理。""" -def _load_dotenv_into_env(project_dir: Path) -> None: - env_path = project_dir / ".env" - if not env_path.exists(): - return - try: - from dotenv import dotenv_values - except ImportError: - return - for key, value in dotenv_values(env_path).items(): - if key and value is not None and os.getenv(key) is None: - os.environ[key] = str(value) - - def _get_hermes_global_env() -> dict[str, str]: global _HERMES_GLOBAL_ENV_CACHE if _HERMES_GLOBAL_ENV_CACHE is not None: @@ -273,6 +268,9 @@ def _build_hermes_env_vars( model_api_key: str | None = None, default_model: str | None = None, model_metadata: dict[str, Any] | None = None, + cli_env: dict[str, str] | None = None, + auto_dotenv: dict[str, str] | None = None, + shell_keys: set[str] | None = None, ) -> list[dict[str, Any]]: raw_model_base_url = model_base_url or _env_value("OPENAI_BASE_URL") resolved_model_base_url = ( @@ -316,26 +314,9 @@ def _build_hermes_env_vars( api_server_key = _env_value("API_SERVER_KEY", "HERMES_API_SERVER_KEY") if api_server_key: raw["API_SERVER_KEY"] = api_server_key - langfuse_public_key = _env_value("HERMES_LANGFUSE_PUBLIC_KEY", "LANGFUSE_PUBLIC_KEY") - langfuse_secret_key = _env_value("HERMES_LANGFUSE_SECRET_KEY", "LANGFUSE_SECRET_KEY") - if langfuse_public_key and langfuse_secret_key: - raw["HERMES_LANGFUSE_PUBLIC_KEY"] = langfuse_public_key - raw["HERMES_LANGFUSE_SECRET_KEY"] = langfuse_secret_key - langfuse_base_url = _env_value( - "HERMES_LANGFUSE_BASE_URL", "LANGFUSE_BASE_URL", "LANGFUSE_HOST" - ) - if langfuse_base_url: - raw["HERMES_LANGFUSE_BASE_URL"] = langfuse_base_url - for target_key, source_keys in { - "HERMES_LANGFUSE_ENV": ("HERMES_LANGFUSE_ENV", "LANGFUSE_ENV"), - "HERMES_LANGFUSE_RELEASE": ("HERMES_LANGFUSE_RELEASE", "LANGFUSE_RELEASE"), - "HERMES_LANGFUSE_SAMPLE_RATE": ("HERMES_LANGFUSE_SAMPLE_RATE",), - "HERMES_LANGFUSE_MAX_CHARS": ("HERMES_LANGFUSE_MAX_CHARS",), - "HERMES_LANGFUSE_DEBUG": ("HERMES_LANGFUSE_DEBUG",), - }.items(): - value = _env_value(*source_keys) - if value: - raw[target_key] = value + # Observability routes and credentials are platform-managed. The Hermes + # deploy CLI must not translate or forward legacy Langfuse SDK variables; + # server/runtime inject the standard OTLP primary and CloudMonitor secondary. for key in ( "WPSXIEZUO_APP_ID", "WPSXIEZUO_APP_KEY", @@ -358,11 +339,15 @@ def _build_hermes_env_vars( "HERMES_FALLBACK_BASE_URL", _env_value("HERMES_FALLBACK_BASE_URL") or resolved_model_base_url, ) + if cli_env or auto_dotenv: + apply_explicit_env_with_shell_priority( + raw, cli_env or {}, auto_dotenv or {}, shell_keys or set(os.environ) + ) return [ { "Key": key, "Value": str(value), - "IsSensitive": any(token in key for token in ("KEY", "TOKEN", "SECRET")), + "IsSensitive": is_sensitive_env_var(key), } for key, value in raw.items() if value is not None and str(value).strip() != "" @@ -500,6 +485,13 @@ def _split_terminal_agent_ref_and_argv( *, validator: Callable[[tuple[str, ...] | list[str]], list[str]], ) -> tuple[str | None, list[str]]: + """Split argv into (agent_ref, exec_argv). + + The first token is treated as agent_ref when the validator rejects the + full argv (whitelist validators raise ValueError), or when the validator + is the passthrough one and the first token looks like an agent id + (``ar-`` prefix) with remaining tokens. + """ raw = [str(item) for item in argv] try: return None, validator(raw) @@ -512,6 +504,19 @@ def _split_terminal_agent_ref_and_argv( raise direct_error +def _passthrough_exec_argv(argv: tuple[str, ...] | list[str]) -> list[str]: + """Local exec passes argv through unchanged; the pod enforces allowlists. + + Raises ValueError when the first token looks like an agent id (``ar-`` + prefix) and there are remaining tokens, so ``_split_terminal_agent_ref_and_argv`` + falls back to splitting the first token as agent_ref. + """ + items = [str(item) for item in argv] + if len(items) >= 2 and items[0].startswith("ar-"): + raise ValueError("first token looks like an agent ref") + return items + + def _render_hermes_dry_run( action: str, request: dict[str, Any], hints: tuple[str, ...] = () ) -> None: @@ -548,6 +553,21 @@ def _render_hermes_dry_run( @click.option("--storage-size-gi", type=int, default=20, show_default=True, help="PVC 容量(Gi)") @click.option("--storage-mount-path", default=None, help="PVC 挂载目录(默认: /home/node/.hermes)") @click.option("--no-storage", is_flag=True, help="禁用默认 PVC 挂载") +@env_options +@click.option( + "--observability/--no-observability", + default=True, + help="是否启用可观测性 (默认开启)", +) +@click.option( + "--agent-id", + "agent_id_opt", + default=None, + help=( + "指定要更新的已有 Agent ID;当前凭证有权限时会自动回填 " + ".agentengine.state 并走热更新(用于本地状态丢失后重新关联)" + ), +) @network_options @dry_run_option() @cli_output_option() @@ -563,6 +583,10 @@ def deploy( storage_size_gi: int, storage_mount_path: Optional[str], no_storage: bool, + extra_env: tuple[str, ...], + env_file: Optional[str], + observability: bool, + agent_id_opt: Optional[str], enable_public_access: Optional[bool], enable_vpc_access: bool, vpc_id: Optional[str], @@ -578,6 +602,8 @@ def deploy( ctx = click.get_current_context(silent=True) include_env_on_update = any( ( + bool(extra_env), + _option_was_explicit(ctx, "env_file"), _option_was_explicit(ctx, "model_base_url"), _option_was_explicit(ctx, "model_api_key"), _option_was_explicit(ctx, "default_model"), @@ -603,8 +629,12 @@ def deploy( storage_size_gi=storage_size_gi, storage_mount_path=storage_mount_path, no_storage=no_storage, + observability=observability, + agent_id=agent_id_opt, include_env_on_update=include_env_on_update, include_storage_on_update=include_storage_on_update, + extra_env=extra_env, + env_file=env_file, **network_cli_kwargs( enable_public_access=enable_public_access, enable_vpc_access=enable_vpc_access, @@ -634,22 +664,43 @@ async def _deploy_hermes( storage_size_gi: int, storage_mount_path: str | None, no_storage: bool, + observability: bool, + agent_id: str | None = None, include_env_on_update: bool, include_storage_on_update: bool, - enable_public_access: bool | None, - enable_vpc_access: bool, - vpc_id: str | None, - subnet_id: str | None, - security_group_id: str | None, - availability_zone: str | None, - dry_run: bool, + extra_env: tuple[str, ...] = (), + env_file: str | None = None, + enable_public_access: bool | None = None, + enable_vpc_access: bool = False, + vpc_id: str | None = None, + subnet_id: str | None = None, + security_group_id: str | None = None, + availability_zone: str | None = None, + dry_run: bool = False, ) -> None: project_dir = Path(".").resolve() - _load_dotenv_into_env(project_dir) + cli_env, auto_dotenv, shell_keys, env_source = resolve_runtime_env_overrides( + env_file=env_file, + extra_env=extra_env, + base_dir=project_dir, + ) + loaded = inject_env_to_environ(cli_env, auto_dotenv, shell_keys) + if loaded: + print_info(f"已从 {env_source or '--env'} 注入环境变量: {loaded} 项") + if cli_env or auto_dotenv: + include_env_on_update = True state = load_state(project_dir) existing_agent_id = None if str(state.get("type") or state.get("framework") or "").strip().lower() == "hermes": existing_agent_id = str(state.get("agent_id") or "").strip() or None + explicit_agent_id = (agent_id or "").strip() or None + if explicit_agent_id: + if existing_agent_id and existing_agent_id != explicit_agent_id: + print_warn( + f"--agent-id ({explicit_agent_id}) 与本地状态 " + f"({existing_agent_id}) 不一致,以 --agent-id 为准" + ) + existing_agent_id = explicit_agent_id agent_name = name or state.get("name") or project_dir.name.replace("-", "_") image_ref = image or _env_value("HERMES_IMAGE", "HERMES_DOCKER_IMAGE") if not image_ref: @@ -676,6 +727,9 @@ async def _deploy_hermes( model_api_key=model_api_key, default_model=default_model, model_metadata=model_metadata, + cli_env=cli_env, + auto_dotenv=auto_dotenv, + shell_keys=shell_keys, ) payload = { "name": agent_name, @@ -686,6 +740,7 @@ async def _deploy_hermes( "region": region, "resources": {"cpu": cpu, "memory": memory}, "scaling": {"min_replicas": 1, "max_replicas": 1, "concurrency": 1000}, + "enable_observability": observability, "env_vars": env_vars, "ui_config": {"profile": "hermes", "path": "/", "url": None}, } @@ -723,6 +778,38 @@ async def _deploy_hermes( print_kv("镜像", image_ref) async with AgentEngineClient(region=region, dry_run=dry_run) as client: + if explicit_agent_id and not dry_run: + try: + detail = await client.get_agent( + explicit_agent_id, include_api_key=True + ) + except Exception as e: + raise click.ClickException( + f"指定的 Agent ID '{explicit_agent_id}' 不存在,或当前凭证无权限访问。\n" + f" 详情: {e}\n" + " 👉 请确认 agent_id 正确,且当前 AK/SK / 账号有该 Agent 的权限。" + ) from e + qa = detail.get("quick_access", {}) or {} + basic = detail.get("basic", {}) or {} + recovered_state = state.copy() + recovered_state.update( + { + "agent_id": explicit_agent_id, + "name": basic.get("name") or agent_name, + "type": "hermes", + "region": region, + "endpoint": qa.get("public_endpoint"), + "updated_at": datetime.now(timezone.utc).isoformat(), + } + ) + if qa.get("api_key"): + recovered_state["api_key"] = qa["api_key"] + recovered_state = { + k: v for k, v in recovered_state.items() if v is not None + } + save_state(project_dir, recovered_state) + state = recovered_state + print_info(f"已通过 --agent-id 关联已有 Agent: {explicit_agent_id}") if existing_agent_id: update_payload = _build_hermes_update_payload( payload=payload, @@ -815,7 +902,7 @@ async def _deploy_hermes( if dry_run: return - agent_id = res.get("agent_id") + final_agent_id = res.get("agent_id") endpoint = res.get("endpoint") api_key = res.get("api_key") deployment_status = normalize_deployment_status(res.get("status") or res.get("phase")) @@ -824,7 +911,7 @@ async def _deploy_hermes( { "type": "hermes", "framework": "hermes", - "agent_id": agent_id, + "agent_id": final_agent_id, "name": res.get("name") or agent_name, "region": region, "endpoint": endpoint, @@ -841,8 +928,8 @@ async def _deploy_hermes( resource="hermes", action="deploy", result={ - "id": str(agent_id or ""), - "agent_id": str(agent_id or ""), + "id": str(final_agent_id or ""), + "agent_id": str(final_agent_id or ""), "name": str(res.get("name") or agent_name), "status": deployment_status, "framework": "hermes", @@ -857,7 +944,7 @@ async def _deploy_hermes( ) return print_success("Hermes 已提交部署") - print_kv("Agent ID", str(agent_id or "(创建中)")) + print_kv("Agent ID", str(final_agent_id or "(创建中)")) print_kv("当前状态", deployment_status, value_style=status_rich_style(deployment_status)) if endpoint: print_kv("Endpoint", str(endpoint), value_style="#58a6ff") @@ -1072,6 +1159,7 @@ def open_hermes( @hermes.command("exec", context_settings=CONTEXT_SETTINGS) @click.argument("argv", nargs=-1, required=True) +@click.option("--agent", "agent_option", default=None, help="Hermes Agent 名称(显式指定)") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") @click.option("--endpoint", "-e", default=None, help="Agent Endpoint URL (覆盖自动获取)") @click.option("--api-key", default=None, help="AgentEngine API Key (覆盖自动获取)") @@ -1081,6 +1169,7 @@ def open_hermes( @cli_output_option() def exec_hermes( argv: tuple[str, ...], + agent_option: Optional[str], region: str, endpoint: Optional[str], api_key: Optional[str], @@ -1092,10 +1181,19 @@ def exec_hermes( """透传受限 Hermes 只读运维子命令。""" _ = output_mode try: - agent_ref, validated_argv = _split_terminal_agent_ref_and_argv( + if agent_option is not None: + agent_option = agent_option.strip() + if not agent_option: + raise click.ClickException("--agent 必须指定非空的 Hermes Agent 名称") + positional_agent_ref, validated_argv = _split_terminal_agent_ref_and_argv( argv, - validator=validate_hermes_exec_argv, + validator=_passthrough_exec_argv, ) + if agent_option is not None and positional_agent_ref: + raise click.ClickException( + "--agent 不能与位置参数 Agent ID 同时使用: " f"{positional_agent_ref}" + ) + agent_ref = agent_option if agent_option is not None else positional_agent_ref dry_run = effective_dry_run(dry_run) if dry_run: _render_hermes_dry_run( @@ -1122,6 +1220,7 @@ def exec_hermes( insecure=insecure, mode="exec", argv=validated_argv, + exec_argv_validator=_passthrough_exec_argv, ) ) except (KeyboardInterrupt, asyncio.CancelledError): diff --git a/ksadk/cli/cmd_launch.py b/ksadk/cli/cmd_launch.py index 622153be..b9e6b3b8 100644 --- a/ksadk/cli/cmd_launch.py +++ b/ksadk/cli/cmd_launch.py @@ -71,6 +71,14 @@ def _dict_or_none(value: object) -> dict[str, Any] | None: help="部署目标 (default: serverless)", ) @click.option("--name", "-n", help="部署名称") +@click.option( + "--agent-id", + default=None, + help=( + "指定要更新的已有 Agent ID;当前凭证有权限时会自动回填 " + ".agentengine.state 并走热更新(用于本地状态丢失后重新关联)" + ), +) @click.option( "--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域 (serverless)" ) @@ -142,6 +150,7 @@ def launch( no_version: bool, auto_rollback: bool, output_mode: str | None, + agent_id: str | None, ): """一键完成构建和部署 (Build + Deploy) @@ -199,6 +208,7 @@ def launch( extra_env=extra_env, env_file=env_file, dry_run_context=dry_run_context, + agent_id=agent_id, ), dry_run=dry_run, on_dry_run=( @@ -246,6 +256,7 @@ async def _launch_async( extra_env: tuple[str, ...] = (), env_file: str | None = None, dry_run_context: dict[str, object] | None = None, + agent_id: str | None = None, ): from ksadk.deployment import DeploymentManager, DeployTarget from ksadk.detection import FrameworkDetector @@ -322,6 +333,7 @@ async def _launch_async( "ui_url": resolved_ui_url, "dry_run": dry_run, "env_vars": explicit_env_vars, + "agent_id": agent_id, }, ) diff --git a/ksadk/cli/cmd_openclaw.py b/ksadk/cli/cmd_openclaw.py index a7dcb50c..3513d559 100644 --- a/ksadk/cli/cmd_openclaw.py +++ b/ksadk/cli/cmd_openclaw.py @@ -41,6 +41,7 @@ ) from ksadk.cli.agent_ref import resolve_openclaw_ref from ksadk.cli.dry_run import dry_run_option, effective_dry_run, run_async_with_dry_run +from ksadk.cli.env_options import env_options, parse_env_pairs from ksadk.cli.error_utils import abort_with_cli_error, remote_error, resolution_error from ksadk.cli.model_catalog import fetch_provider_model_catalog, find_model_in_catalog from ksadk.cli.network_options import build_network_payload, network_cli_kwargs, network_options @@ -77,6 +78,7 @@ from ksadk.cli.ui import ( output_option as cli_output_option, ) +from ksadk.configs.env_registry import is_sensitive_env_var from ksadk.conversations.model_context import normalize_model_metadata from ksadk.deployment.agent_access import get_latest_agent_access from ksadk.model_policy import build_runtime_model_policy_env @@ -107,7 +109,6 @@ _GLOBAL_ENV_CACHE: Optional[Dict[str, str]] = None OPENCLAW_SECURITY_PROFILES = ("relaxed", "strict", "strictest") OPENCLAW_CHANNELS = ("weixin", "feishu", "wps-xiezuo") -OPENCLAW_ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") WEIXIN_PLUGIN_ID = "openclaw-weixin" FEISHU_PLUGIN_ID = "openclaw-lark" FEISHU_CHANNEL_KEY = "feishu" @@ -1055,22 +1056,13 @@ def _normalize_openclaw_gateway_auth_env(env: dict[str, str]) -> dict[str, str]: def _parse_extra_openclaw_env_pairs(items: tuple[str, ...] | list[str] | None) -> dict[str, str]: - """解析 deploy --env 传入的自定义环境变量。""" - parsed: dict[str, str] = {} - for raw_item in items or (): - item = str(raw_item or "").strip() - if not item or "=" not in item: - raise ValueError(f"自定义环境变量格式错误: {raw_item!r},应为 KEY=VALUE") - key, value = item.split("=", 1) - key = key.strip() - if not OPENCLAW_ENV_KEY_PATTERN.fullmatch(key): - raise ValueError(f"自定义环境变量名不合法: {key!r},请使用合法的环境变量名") - if key == "OPENCLAW_GATEWAY_AUTH_MODE": - normalized = value.strip().lower() - if normalized not in {"trusted-proxy", "token", "none"}: - raise ValueError("OPENCLAW_GATEWAY_AUTH_MODE 仅支持 trusted-proxy、token 或 none") - value = normalized - parsed[key] = value + """解析 deploy --env 传入的自定义环境变量,并对 gateway 鉴权模式做早期归一化。""" + parsed = parse_env_pairs(items) + if "OPENCLAW_GATEWAY_AUTH_MODE" in parsed: + normalized = parsed["OPENCLAW_GATEWAY_AUTH_MODE"].strip().lower() + if normalized not in {"trusted-proxy", "token", "none"}: + raise ValueError("OPENCLAW_GATEWAY_AUTH_MODE 仅支持 trusted-proxy、token 或 none") + parsed["OPENCLAW_GATEWAY_AUTH_MODE"] = normalized return parsed @@ -3388,17 +3380,21 @@ async def _run(): @click.option("--mem0-instance-id", default=None, help="mem0 实例 ID") @click.option("--mem0-instance-name", default=None, help="mem0 实例名称(可选)") @click.option("--mem0-region", default=None, help="mem0 实例区域(可选)") -@click.option( - "--env", - "extra_env", - multiple=True, - help="额外透传自定义环境变量,格式 KEY=VALUE,可重复传入", -) +@env_options @click.option("--storage-size-gi", type=int, default=20, show_default=True, help="PVC 容量(Gi)") @click.option( "--storage-mount-path", default=None, help="PVC 挂载目录(默认: /home/node/.openclaw)" ) @click.option("--no-storage", is_flag=True, help="禁用默认 PVC 挂载") +@click.option( + "--agent-id", + "agent_id_opt", + default=None, + help=( + "指定要更新的已有 Agent ID;当前凭证有权限时会自动回填 " + ".agentengine.state 并走热更新(用于本地状态丢失后重新关联)" + ), +) @network_options @dry_run_option("仅显示请求,不实际部署") def deploy( @@ -3414,9 +3410,11 @@ def deploy( mem0_instance_name: Optional[str], mem0_region: Optional[str], extra_env: tuple[str, ...], + env_file: Optional[str], storage_size_gi: int, storage_mount_path: Optional[str], no_storage: bool, + agent_id_opt: Optional[str], enable_public_access: Optional[bool], enable_vpc_access: bool, vpc_id: Optional[str], @@ -3456,6 +3454,7 @@ def deploy( _option_was_explicit(ctx, "model_api_key"), _option_was_explicit(ctx, "default_model"), bool(extra_env), + _option_was_explicit(ctx, "env_file"), ) ) include_storage_on_update = any( @@ -3486,9 +3485,11 @@ def deploy( mem0_instance_name=mem0_instance_name, mem0_region=mem0_region, extra_env=extra_env, + env_file=env_file, storage_size_gi=storage_size_gi, storage_mount_path=storage_mount_path, no_storage=no_storage, + agent_id_opt=agent_id_opt, include_env_on_update=include_env_on_update, include_storage_on_update=include_storage_on_update, **network_cli_kwargs( @@ -3521,9 +3522,11 @@ async def _deploy_openclaw( mem0_instance_name: Optional[str], mem0_region: Optional[str], extra_env: tuple[str, ...] = (), + env_file: Optional[str] = None, storage_size_gi: int = 20, storage_mount_path: Optional[str] = None, no_storage: bool = False, + agent_id_opt: Optional[str] = None, include_env_on_update: bool = False, include_storage_on_update: bool = False, enable_public_access: Optional[bool] = None, @@ -3535,27 +3538,26 @@ async def _deploy_openclaw( dry_run: bool, ): """异步部署 OpenClaw""" - from dotenv import dotenv_values - from ksadk.api import AgentEngineClient + from ksadk.cli.env_options import ( + apply_explicit_env_with_shell_priority, + inject_env_to_environ, + resolve_runtime_env_overrides, + ) from ksadk.deployment.state import clear_state, load_state, save_state - # 自动加载当前目录 .env(仅补充未导出的变量,不覆盖已导出的 shell 环境) + # 自动加载当前目录 .env 或 --env-file 指定文件(优先级: --env > --env-file > shell > .env) project_dir = Path(".").resolve() - env_file = project_dir / ".env" - if env_file.exists(): - try: - loaded = 0 - for k, v in dotenv_values(env_file).items(): - if not k or v is None: - continue - if os.getenv(k) is None: - os.environ[k] = str(v) - loaded += 1 - if loaded: - print_info(f"已从 .env 注入环境变量: {loaded} 项") - except Exception as e: - print_warn(f"读取 .env 失败,将继续使用当前 shell 环境: {e}") + cli_env, auto_dotenv, shell_keys, env_source = resolve_runtime_env_overrides( + env_file=env_file, + extra_env=extra_env, + base_dir=project_dir, + ) + loaded = inject_env_to_environ(cli_env, auto_dotenv, shell_keys) + if loaded: + print_info(f"已从 {env_source or '--env'} 注入环境变量: {loaded} 项") + if cli_env or auto_dotenv: + include_env_on_update = True # 读取本地状态 (判断创建 vs 更新) state = load_state(project_dir) @@ -3565,6 +3567,14 @@ async def _deploy_openclaw( if state_kind == "openclaw": existing_agent_id = state.get("agent_id") state_name = str(state.get("name") or "").strip() or None + explicit_agent_id = (agent_id_opt or "").strip() or None + if explicit_agent_id: + if existing_agent_id and existing_agent_id != explicit_agent_id: + print_info( + f"--agent-id ({explicit_agent_id}) 与本地状态 " + f"({existing_agent_id}) 不一致,以 --agent-id 为准" + ) + existing_agent_id = explicit_agent_id if name: openclaw_name = name @@ -3592,9 +3602,8 @@ async def _deploy_openclaw( default_model=default_model, security_profile=security_profile, ) - custom_env_vars = _parse_extra_openclaw_env_pairs(extra_env) - if custom_env_vars: - env_vars.update(custom_env_vars) + if cli_env or auto_dotenv: + apply_explicit_env_with_shell_priority(env_vars, cli_env, auto_dotenv, shell_keys) env_vars = _normalize_openclaw_gateway_auth_env(env_vars) if not str(env_vars.get("OPENCLAW_MODEL_CATALOG_JSON") or "").strip(): catalog_api_base = model_base_url or _resolve_env( @@ -3636,7 +3645,7 @@ async def _deploy_openclaw( # 构建环境变量列表 env_list = [ - {"Key": k, "Value": v, "IsSensitive": "KEY" in k or "TOKEN" in k or "SECRET" in k} + {"Key": k, "Value": v, "IsSensitive": is_sensitive_env_var(k)} for k, v in env_vars.items() ] # 资源规格(支持通过环境变量覆盖) @@ -3744,6 +3753,37 @@ async def _deploy_openclaw( latest_status = None updated_existing_agent = False async with AgentEngineClient(region=region) as client: + if explicit_agent_id: + try: + detail = await client.get_agent( + explicit_agent_id, include_api_key=True + ) + except Exception as e: + raise click.ClickException( + f"指定的 Agent ID '{explicit_agent_id}' 不存在,或当前凭证无权限访问。\n" + f" 详情: {e}\n" + " 👉 请确认 agent_id 正确,且当前 AK/SK / 账号有该 Agent 的权限。" + ) from e + qa = detail.get("quick_access", {}) or {} + basic = detail.get("basic", {}) or {} + recovered_state = state.copy() + recovered_state.update( + { + "agent_id": explicit_agent_id, + "name": basic.get("name") or openclaw_name, + "type": "openclaw", + "region": region, + "endpoint": qa.get("public_endpoint"), + } + ) + if qa.get("api_key"): + recovered_state["api_key"] = qa["api_key"] + recovered_state = { + k: v for k, v in recovered_state.items() if v is not None + } + save_state(project_dir, recovered_state) + state = recovered_state + print_info(f"已通过 --agent-id 关联已有 Agent: {explicit_agent_id}") if existing_agent_id: print_info(f"检测到本地状态: {existing_agent_id},执行更新...") try: diff --git a/ksadk/cli/cmd_run.py b/ksadk/cli/cmd_run.py index d962c0f7..4678401e 100644 --- a/ksadk/cli/cmd_run.py +++ b/ksadk/cli/cmd_run.py @@ -11,14 +11,15 @@ from pathlib import Path import click +import uvicorn from ksadk.cli.error_utils import ensure_json_output_supported, print_exception from ksadk.cli.local_runtime import reexec_with_project_venv_if_needed +from ksadk.cli.runtime_bootstrap import create_runtime_web_app from ksadk.cli.ui import ( print_error, print_info, print_kv, - print_rule, print_success, print_title, print_warn, @@ -117,59 +118,45 @@ def run( configure_local_runtime_persistence(agent_path, result.type.value) # 2. 根据框架类型选择处理方式 - # 所有框架统一使用 _run_custom() 以支持 Langfuse 自动插桩 - # (Langfuse instrumentation 需要在同一进程内生效) + # 所有框架统一使用 _run_custom(),让 OTel instrumentation 在同一进程生效。 _run_custom( result, agent_path, port, interactive, no_trace, show_thinking, no_stream, no_alt_screen ) def _run_adk_cli(agent_path: Path, port: int = 8080, command: str = "run"): - """运行 ADK Agent,支持 Langfuse tracing - - 对于 Langfuse 集成,我们需要在同一进程内运行 ADK, - 因为 OpenTelemetry instrumentation 只在进程内生效。 - """ + """运行 ADK Agent,使用标准 OTLP 配置。""" import os - has_langfuse = bool(os.getenv("LANGFUSE_PUBLIC_KEY")) + has_otlp = bool( + os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + or os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + or os.getenv("CLOUD_MONITOR_OTLP_ENDPOINT") + or os.getenv("CLOUD_MONITOR_OTLP_TRACES_ENDPOINT") + ) # 必须先初始化 Tracing(在导入 ADK 之前) - if has_langfuse: + if has_otlp: try: from ksadk.tracing import setup_tracing setup_tracing( enable_inmemory=False, - enable_langfuse=True, enable_adk_instrumentation=True, ) - print_info("Tracing: Enabled (Langfuse + ADK Instrumentation)") + print_info("Tracing: Enabled (OTLP + ADK Instrumentation)") except Exception as e: - print_warn(f"Langfuse 初始化失败: {e}") + print_warn(f"OTLP tracing 初始化失败: {e}") print_kv("调用 ADK 原生 CLI", f"adk {command}") - # 使用 subprocess(Langfuse instrumentation 在子进程中不生效,但环境变量会传递) - # ADK CLI 本身不支持 Langfuse,需要用户在项目中集成 + # 子进程继承标准 OTLP 环境变量;项目内 instrumentation 负责创建 spans。 if command == "run": cmd = [sys.executable, "-m", "google.adk.cli", "run", "."] else: cmd = [sys.executable, "-m", "google.adk.cli", "web", ".", "--port", str(port)] - # 传递 Langfuse 环境变量 env = os.environ.copy() - langfuse_vars = ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_BASE_URL"] - for var in langfuse_vars: - if var in os.environ: - env[var] = os.environ[var] - - # 提示用户如何在 ADK 项目中启用 Langfuse - if has_langfuse: - print_rule("ADK 项目 Langfuse 集成提示") - print_info("在 agent.py 中添加以下代码:") - print_info("from openinference.instrumentation.google_adk import GoogleADKInstrumentor") - print_info("GoogleADKInstrumentor().instrument()") try: subprocess.run(cmd, cwd=str(agent_path), check=True, env=env) @@ -191,8 +178,7 @@ def _run_custom( no_stream: bool = False, no_alt_screen: bool = False, ): - """使用自定义实现 (LangChain/LangGraph/DeepAgents)""" - from ksadk.runners.factory import create_runner + """Run one detected project through the canonical RuntimeAdapter composition.""" # 初始化 Tracing if not no_trace: @@ -201,62 +187,38 @@ def _run_custom( from ksadk.tracing import setup_tracing - has_langfuse = bool(os.getenv("LANGFUSE_PUBLIC_KEY")) has_otlp = bool( os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") or os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") ) - use_callback_only = os.getenv("LANGFUSE_USE_CALLBACK", "").strip().lower() in ( - "1", - "true", - "yes", - "on", - ) - setup_tracing( enable_inmemory=True, enable_langfuse=None, - use_callback_only=use_callback_only, ) if has_otlp: print_info("Tracing: Enabled (InMemory + OTLP HTTP)") - elif has_langfuse: - print_info( - f"Tracing: Enabled (InMemory + Langfuse, CallbackOnly={use_callback_only})" - ) else: print_info("Tracing: Enabled") except Exception as e: print_warn(f"Tracing 初始化失败: {e}") - # 创建 Runner + if interactive: + del show_thinking, no_stream, no_alt_screen + print_error( + "RuntimeAdapter TUI 尚未接通;请使用 `ksadk web` 或不带 --interactive 启动" + ) + raise SystemExit(2) + try: - print_info("初始化 Runner...") - runner = create_runner(result, str(agent_path)) - runner.load_agent() - print_success("Agent 加载成功") + runtime_app = create_runtime_web_app(result, agent_path) except Exception as e: - print_exception("Agent 加载失败", e) - # import traceback - # traceback.print_exc() + print_exception("RuntimeAdapter 初始化失败", e) raise SystemExit(1) - # 运行 - if interactive: - # TUI 交互模式 - from ksadk.tui.loop import run_tui - - run_tui( - runner, - show_thinking=show_thinking, - project_dir=str(agent_path), - no_alt_screen=no_alt_screen, - ) - else: - print_success(f"Server running at http://0.0.0.0:{port}") - print_kv("API Docs", f"http://0.0.0.0:{port}/docs") - print_kv("Chat API", f"http://0.0.0.0:{port}/chat") - print_info("Press Ctrl+C to stop") - runner.run_server(port=port) + print_success(f"Server running at http://127.0.0.1:{port}") + print_kv("API Docs", f"http://127.0.0.1:{port}/docs") + print_kv("Chat API", f"http://127.0.0.1:{port}/chat") + print_info("Press Ctrl+C to stop") + uvicorn.run(runtime_app, host="127.0.0.1", port=port) diff --git a/ksadk/cli/cmd_studio.py b/ksadk/cli/cmd_studio.py new file mode 100644 index 00000000..e92adac7 --- /dev/null +++ b/ksadk/cli/cmd_studio.py @@ -0,0 +1,110 @@ +"""agentengine studio - start the local-first Agent authoring control plane.""" + +from __future__ import annotations + +import os +import secrets +import webbrowser +from pathlib import Path + +import click +import uvicorn + +from ksadk.cli.env_options import load_env_file +from ksadk.cli.ui import print_info, print_kv, print_success, print_title +from ksadk.studio.api import create_studio_app +from ksadk.studio.service import StudioService + +_MODEL_ENV_KEYS = ( + "OPENAI_API_BASE", + "OPENAI_API_KEY", + "OPENAI_MODEL_NAME", +) + + +@click.command(context_settings=dict(help_option_names=["-h", "--help"])) +@click.argument("workspace", default=".", type=click.Path()) +@click.option("--port", "-p", default=8080, type=click.IntRange(1, 65535)) +@click.option("--no-open", is_flag=True, help="仅打印 URL,不自动打开浏览器") +@click.option( + "--env-file", + type=click.Path(exists=True, dir_okay=False), + help="模型环境文件;只读取 OPENAI_API_BASE/API_KEY/MODEL_NAME", +) +@click.option( + "--codex-proxy", + type=click.Choice(["inherit", "auto", "forced", "direct"]), + default="inherit", + show_default=True, + help="Codex Responses→Chat 代理策略", +) +def studio( + workspace: str, + port: int, + no_open: bool, + env_file: str | None, + codex_proxy: str, +) -> None: + """启动本地 AgentKit Studio。 + + \b + WORKSPACE: Agent 工作区目录,目录不存在时自动初始化。 + """ + + root = Path(workspace).expanduser().resolve() + managed_keys = (*_MODEL_ENV_KEYS, "KSADK_CODEX_USE_PROXY") + previous = {key: os.environ.get(key) for key in managed_keys} + previously_present = {key for key in managed_keys if key in os.environ} + try: + if env_file: + try: + values = load_env_file(env_file) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + loaded = 0 + for key, value in values.items(): + if key not in _MODEL_ENV_KEYS or not value: + continue + loaded += 1 + if key not in os.environ: + os.environ[key] = value + print_kv("模型环境", f"已安全加载 {loaded}/{len(_MODEL_ENV_KEYS)} 个字段") + if codex_proxy == "forced": + os.environ["KSADK_CODEX_USE_PROXY"] = "1" + elif codex_proxy == "direct": + os.environ["KSADK_CODEX_USE_PROXY"] = "0" + elif codex_proxy == "auto": + os.environ.pop("KSADK_CODEX_USE_PROXY", None) + session_token = os.environ.get("KSADK_STUDIO_SESSION_TOKEN") or secrets.token_urlsafe(32) + csrf_token = secrets.token_urlsafe(24) + service = StudioService(root) + app = create_studio_app( + root, + service=service, + session_token=session_token, + csrf_token=csrf_token, + security_enabled=os.environ.get("KSADK_STUDIO_NO_SECURITY") != "1", + ) + base_url = f"http://127.0.0.1:{port}/" + launch_url = f"{base_url}#session={session_token}" + + print_title("启动 AgentKit Local Studio") + print_kv("工作区", str(root)) + print_kv("访问地址", launch_url, value_style="#58a6ff") + print_success("构建与运行均在本地执行") + print_info("按 Ctrl+C 停止") + if not no_open: + webbrowser.open(launch_url) + uvicorn.run( + app, + host="127.0.0.1", + port=port, + log_level="info", + access_log=False, + ) + finally: + for key in managed_keys: + if key in previously_present: + os.environ[key] = previous[key] or "" + else: + os.environ.pop(key, None) diff --git a/ksadk/cli/cmd_web.py b/ksadk/cli/cmd_web.py index be7a97d6..a9a38bf1 100644 --- a/ksadk/cli/cmd_web.py +++ b/ksadk/cli/cmd_web.py @@ -6,10 +6,12 @@ from pathlib import Path import click +import uvicorn import yaml from ksadk.cli.error_utils import ensure_json_output_supported, print_exception from ksadk.cli.local_runtime import reexec_with_project_venv_if_needed +from ksadk.cli.runtime_bootstrap import create_runtime_web_app from ksadk.cli.ui import ( print_error, print_info, @@ -20,7 +22,6 @@ ) from ksadk.configs import setup_environment from ksadk.detection import FrameworkDetector -from ksadk.runners.factory import create_runner _PERSISTENT_STM_FRAMEWORKS = {"adk", "langgraph", "langchain", "deepagents"} _STM_ENV_NAMES = ( @@ -340,10 +341,10 @@ def web(agent_dir: str, port: int, model: str, no_open: bool): launch_path = _configure_custom_ui_env(agent_path) try: - print_info("初始化 Runner...") - runner = create_runner(result, str(agent_path)) + print_info("初始化 RuntimeAdapter...") + runtime_app = create_runtime_web_app(result, agent_path) except Exception as e: - print_exception("Runner 初始化失败", e) + print_exception("RuntimeAdapter 初始化失败", e) raise SystemExit(1) print_success("启动统一 Web UI") @@ -356,7 +357,7 @@ def web(agent_dir: str, port: int, model: str, no_open: bool): webbrowser.open(launch_url) try: - runner.run_server(port=port) + uvicorn.run(runtime_app, host="127.0.0.1", port=port) except KeyboardInterrupt: raise SystemExit(0) except Exception as e: diff --git a/ksadk/cli/dry_run.py b/ksadk/cli/dry_run.py index a8325b6d..719d8f80 100644 --- a/ksadk/cli/dry_run.py +++ b/ksadk/cli/dry_run.py @@ -13,6 +13,7 @@ from ksadk.api.client import DryRunExit from ksadk.cli.resource_common import build_dry_run_envelope from ksadk.cli.ui import emit_json, is_json_output +from ksadk.configs.env_registry import is_sensitive_env_var _T = TypeVar("_T") _DEFAULT_DONE_MSG = "✅ Dry Run Completed: 请求已打印,未执行实际变更。" @@ -117,7 +118,7 @@ def _mask_if_sensitive(value: Any, *, sensitive: bool) -> Any: def _sanitize_dry_run_value(value: Any, *, parent_key: str | None = None) -> Any: if isinstance(value, dict): env_key = str(value.get("Key") or "") - env_sensitive = bool(value.get("IsSensitive")) or _is_sensitive_key(env_key) + env_sensitive = bool(value.get("IsSensitive")) or is_sensitive_env_var(env_key) sanitized: dict[str, Any] = {} for key, item in value.items(): key_text = str(key) diff --git a/ksadk/cli/env_options.py b/ksadk/cli/env_options.py index 70d3b965..921fa04b 100644 --- a/ksadk/cli/env_options.py +++ b/ksadk/cli/env_options.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import re from pathlib import Path @@ -62,11 +63,93 @@ def resolve_explicit_env_vars( env_pairs: tuple[str, ...] | list[str] | None, base_dir: Path, ) -> dict[str, str]: + """Resolve explicit env vars from ``--env-file``/``--env`` (no auto-``./.env``). + + Used by the generic ``agentengine deploy``/``launch`` which read model + config from ``agentengine.yaml``, not from ``./.env``. Framework-specific + deploys (hermes/openclaw) use ``resolve_runtime_env_overrides`` instead, + which auto-discovers ``./.env`` and returns shell_keys for os.environ merge. + """ env_vars = load_env_file(env_file, base_dir=base_dir) env_vars.update(parse_env_pairs(env_pairs)) return env_vars +def apply_explicit_env_with_shell_priority( + base_env: dict[str, str], + cli_env: dict[str, str], + auto_dotenv: dict[str, str], + shell_keys: set[str], +) -> dict[str, str]: + """Merge env into base with precedence: cli_env > shell > auto_dotenv. + + ``shell_keys`` is the snapshot of ``os.environ`` keys captured BEFORE any + env load. ``cli_env`` (``--env``/``--env-file``) is the highest explicit + CLI intent and overwrites everything. ``auto_dotenv`` (auto-discovered + ``./.env``) only fills keys not already set by shell. + """ + for key, value in auto_dotenv.items(): + if key in shell_keys: + continue + base_env.setdefault(key, value) + for key, value in cli_env.items(): + base_env[key] = value + return base_env + + +def inject_env_to_environ( + cli_env: dict[str, str], + auto_dotenv: dict[str, str], + shell_keys: set[str], +) -> int: + """Apply env to ``os.environ`` with precedence: cli_env > shell > auto_dotenv. + + ``auto_dotenv`` only fills keys absent from shell (shell wins over auto + ``./.env``). ``cli_env`` overwrites shell (explicit CLI intent wins). + Returns the number of keys injected from ``auto_dotenv`` (for user-facing + messages); ``cli_env`` overwrites are not counted. + """ + loaded = 0 + for key, value in auto_dotenv.items(): + if key in shell_keys: + continue + os.environ.setdefault(key, value) + loaded += 1 + for key, value in cli_env.items(): + os.environ[key] = value + return loaded + + +def resolve_runtime_env_overrides( + *, + env_file: str | None, + extra_env: tuple[str, ...] | list[str] | None, + base_dir: Path, +) -> tuple[dict[str, str], dict[str, str], set[str], str | None]: + """Resolve runtime env overrides with precedence ``--env > --env-file > shell > auto ./.env``. + + Returns ``(cli_env, auto_dotenv, shell_keys, source)``: + - ``cli_env``: merged ``--env-file`` + ``--env`` (``--env`` wins); explicit + CLI intent, overwrites shell at merge time. + - ``auto_dotenv``: auto-discovered ``./.env`` (only when ``env_file`` is + None); does NOT overwrite shell. + - ``shell_keys``: snapshot of ``os.environ`` captured before any load. + - ``source``: the file path used (for user-facing messages) or ``None``. + """ + shell_keys = set(os.environ) + explicit_file_env = load_env_file(env_file, base_dir=base_dir) if env_file else {} + auto_dotenv: dict[str, str] = {} + auto_source: str | None = None + if not env_file: + auto_path = base_dir / ".env" + if auto_path.exists(): + auto_dotenv = load_env_file(str(auto_path), base_dir=base_dir) + auto_source = str(auto_path) + pair_env = parse_env_pairs(extra_env) + cli_env = {**explicit_file_env, **pair_env} + return cli_env, auto_dotenv, shell_keys, env_file or auto_source + + def env_options(func): func = click.option( "--env-file", diff --git a/ksadk/cli/global_options.py b/ksadk/cli/global_options.py index 6dddca02..19767309 100644 --- a/ksadk/cli/global_options.py +++ b/ksadk/cli/global_options.py @@ -18,7 +18,9 @@ def ensure_global_cli_options(command: click.Command) -> click.Command: """Inject hidden common options into groups/commands that do not declare them locally.""" if not _has_long_option(command, "--dry-run"): command.params.insert(0, build_dry_run_click_option(hidden=True, expose_value=False)) - if not _has_long_option(command, "--output"): + if not getattr(command, "disable_global_output_option", False) and not _has_long_option( + command, "--output" + ): command.params.insert(0, build_output_click_option(hidden=True, expose_value=False)) if not _has_long_option(command, "--no-color"): command.params.insert(0, build_no_color_click_option(hidden=True, expose_value=False)) diff --git a/ksadk/cli/network_options.py b/ksadk/cli/network_options.py index 1f3a24bc..7eefa8e9 100644 --- a/ksadk/cli/network_options.py +++ b/ksadk/cli/network_options.py @@ -316,11 +316,13 @@ def _resolve_subnet_availability_zone(*, subnet_id: str, region: str) -> str | N def _import_vpc_sdk(): - from ksyun.client.vpc.v20160304.client import VpcClient - from ksyun.client.vpc.v20160304.models import DescribeSubnetsRequest - from ksyun.common.credential import Credential - from ksyun.common.profile.client_profile import ClientProfile - from ksyun.common.profile.http_profile import HttpProfile + from ksyun.client.vpc.v20160304.client import VpcClient # type: ignore[import-untyped] + from ksyun.client.vpc.v20160304.models import ( # type: ignore[import-untyped] + DescribeSubnetsRequest, + ) + from ksyun.common.credential import Credential # type: ignore[import-untyped] + from ksyun.common.profile.client_profile import ClientProfile # type: ignore[import-untyped] + from ksyun.common.profile.http_profile import HttpProfile # type: ignore[import-untyped] return VpcClient, DescribeSubnetsRequest, Credential, ClientProfile, HttpProfile diff --git a/ksadk/cli/runtime_bootstrap.py b/ksadk/cli/runtime_bootstrap.py new file mode 100644 index 00000000..e146a51f --- /dev/null +++ b/ksadk/cli/runtime_bootstrap.py @@ -0,0 +1,51 @@ +"""Shared RuntimeAdapter-first application composition for CLI entrypoints.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from fastapi import FastAPI + +from ksadk.agui.config import agui_config_for_detection +from ksadk.runtime import RuntimeExecutor, RuntimeLaunchContext +from ksadk.runtime.factory import build_default_runtime_registry +from ksadk.server.composition import configure_runtime_app +from ksadk.server.factory import RuntimeAppConfig, create_runtime_app + + +def _managed_a2a_card() -> Any: + """Build the managed discovery-only card when the runtime opts in. + + Import is deferred so the A2A package is only loaded when + ``KSADK_A2A_RUNTIME_ID`` is configured. + """ + if not os.getenv("KSADK_A2A_RUNTIME_ID", "").strip(): + return None + from ksadk.managed_a2a_card import build_managed_a2a_card_if_configured + + return build_managed_a2a_card_if_configured() + + +def create_runtime_web_app(detection: Any, agent_path: Path) -> FastAPI: + """Compose one detected project around the canonical RuntimeExecutor.""" + + context = RuntimeLaunchContext( + runtime_type=str(detection.type.value), + project_dir=agent_path, + detection=detection, + config=dict(getattr(detection, "raw_config", None) or {}), + ) + return create_runtime_app( + RuntimeAppConfig( + runtime_executor=RuntimeExecutor(build_default_runtime_registry()), + launch_context=context, + agui=agui_config_for_detection(detection), + a2a=_managed_a2a_card(), + ), + configure_runtime_app, + ) + + +__all__ = ["create_runtime_web_app"] diff --git a/ksadk/cli/ui.py b/ksadk/cli/ui.py index 7c24c9ec..d8a2f843 100644 --- a/ksadk/cli/ui.py +++ b/ksadk/cli/ui.py @@ -27,9 +27,12 @@ "RUNNING": "bold #2da44e", "READY": "bold #2da44e", "HEALTHY": "bold #2da44e", + "PASSED": "bold #2da44e", "CREATING": "bold #d29922", "SUBMITTED": "bold #d29922", "PENDING": "bold #d29922", + "CANCELLED": "bold #d29922", + "UNAVAILABLE": "bold #d29922", "UPDATING": "bold #d29922", "SCALING": "bold #d29922", "FAILED": "bold #f85149", @@ -42,9 +45,12 @@ "RUNNING": "green", "READY": "green", "HEALTHY": "green", + "PASSED": "green", "CREATING": "yellow", "SUBMITTED": "yellow", "PENDING": "yellow", + "CANCELLED": "yellow", + "UNAVAILABLE": "yellow", "UPDATING": "yellow", "SCALING": "yellow", "FAILED": "red", diff --git a/ksadk/cli/workflow_common.py b/ksadk/cli/workflow_common.py index 14a66cea..6b40193c 100644 --- a/ksadk/cli/workflow_common.py +++ b/ksadk/cli/workflow_common.py @@ -133,11 +133,11 @@ def should_build_artifact( mode = (artifact_type or "Code").strip().lower() if mode == "code": return not bool(ks3_path) - # ManagedRuntime is a declarative server-side manifest. ``ksadk build`` - # remains available for inspection/offline locking, but ``deploy`` neither - # uploads nor needs a local artifact. + # ManagedRuntime is a declarative server-side manifest. Deployment does + # not upload an artifact, but it must build the canonical manifest locally + # to include its SHA-256 in the control-plane request. if mode == "managedruntime": - return False + return True if mode == "container": return not bool(image) return False @@ -261,7 +261,7 @@ def resolve_artifact_build_plan( reference_is_predicted=False, ) - if plan.should_build and dry_run: + if plan.should_build and dry_run and not is_managed_runtime: return replace( plan, will_build=False, diff --git a/ksadk/codex/__init__.py b/ksadk/codex/__init__.py index c13f646a..8a088668 100644 --- a/ksadk/codex/__init__.py +++ b/ksadk/codex/__init__.py @@ -1,12 +1,12 @@ -"""CodexRuntime — 非 ADK 体系 runtime (goal-09)。""" +"""CodexRuntimeAdapter — 非 ADK 体系 runtime (goal-09)。""" from ksadk.codex.client import AsyncCodexClient, CodexClient from ksadk.codex.phase import CodexPhaseTracker -from ksadk.codex.runtime import CodexRuntime +from ksadk.codex.runtime import CodexRuntimeAdapter __all__ = [ "AsyncCodexClient", "CodexClient", "CodexPhaseTracker", - "CodexRuntime", + "CodexRuntimeAdapter", ] diff --git a/ksadk/codex/client.py b/ksadk/codex/client.py index 928f22e9..6f75842a 100644 --- a/ksadk/codex/client.py +++ b/ksadk/codex/client.py @@ -1,6 +1,6 @@ -"""CodexRuntime 的 codex 后端接口 (goal-09)。 +"""CodexRuntimeAdapter 的 codex 后端接口 (goal-09)。 -重托管模式:CodexRuntime 对执行生命周期负责,不把 cancel/进程管理薄委托给上层。 +重托管模式:CodexRuntimeAdapter 对执行生命周期负责,不把 cancel/进程管理薄委托给上层。 ``CodexClient`` 是 codex 后端(thread/turn 生命周期)的最小接口,方法集对齐 OpenAI 官方 ``openai-codex`` SDK 的**真实**线程模型(``thread_start`` / ``thread.turn`` / ``handle.stream`` / ``handle.interrupt`` / ``thread_resume``): @@ -18,11 +18,16 @@ from __future__ import annotations +import asyncio import os +import queue import secrets +import threading from abc import ABC, abstractmethod +from dataclasses import dataclass, field from importlib.metadata import PackageNotFoundError, version from typing import Any, AsyncIterator, Optional +from uuid import uuid4 from ksadk.model_proxy import ProxyConfig, ProxyServer from ksadk.model_proxy.cache import CapabilityCache, credential_scope @@ -32,6 +37,26 @@ _CAPABILITY_CACHE = CapabilityCache(ttl=3600) +@dataclass +class _PendingApproval: + approval_id: str + thread_id: str + method: str + params: dict[str, Any] + resolved: threading.Event = field(default_factory=threading.Event) + response: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class _PendingInteraction: + interaction_id: str + thread_id: str + method: str + params: dict[str, Any] + resolved: threading.Event = field(default_factory=threading.Event) + response: dict[str, Any] = field(default_factory=dict) + + def _is_openai_official(base: str) -> bool: """OpenAI 官方 base_url(直连,不探测不代理)。""" from urllib.parse import urlsplit @@ -93,6 +118,17 @@ def run_turn( 产出规范化事件 dict(``{"method": ..., "params": ...}``)。""" raise NotImplementedError + def run_goal( + self, + thread_id: str, + objective: str, + *, + config: Optional[dict[str, Any]] = None, + ) -> AsyncIterator[dict[str, Any]]: + """Run one persisted Codex goal as a logical notification stream.""" + + raise RuntimeError("connected Codex client does not support goal operations") + @abstractmethod async def interrupt_active_turn(self, thread_id: str) -> bool: """interrupt 该 thread 当前活跃 turn(真实 SDK ``handle.interrupt``); @@ -104,6 +140,35 @@ async def resume_thread(self, thread_id: str, config: Optional[dict[str, Any]] = """恢复 thread(真实 SDK ``thread_resume``),返回 thread_id。""" raise NotImplementedError + async def resolve_approval(self, approval_id: str, decision: str) -> bool: + """Resolve one live native approval request. + + Older/custom clients may not expose interactive approvals; returning + ``False`` keeps that capability boundary explicit without breaking the + existing client protocol. + """ + + return False + + async def resolve_interaction( + self, + interaction_id: str, + data: dict[str, Any], + ) -> bool: + """Resolve one live structured user-input request.""" + + return False + + async def pause_goal(self, thread_id: str) -> bool: + """Pause an active native goal when supported.""" + + return False + + async def cancel_goal(self, thread_id: str) -> bool: + """Cancel an active native goal operation when supported.""" + + return False + @abstractmethod async def close(self) -> None: """释放后端资源(真实 SDK ``close``)。""" @@ -112,7 +177,7 @@ async def close(self) -> None: def _missing_codex_error() -> RuntimeError: return RuntimeError( - "CodexRuntime 需要 openai-codex SDK;请安装 ksadk[codex]:" + "CodexRuntimeAdapter 需要 openai-codex SDK;请安装 ksadk[codex]:" " pip install 'ksadk[codex]'(openai-codex 为可选 extra,不进默认依赖)" ) @@ -126,7 +191,7 @@ class AsyncCodexClient(CodexClient): ``AsyncThread.turn`` / ``AsyncTurnHandle.stream``/``interrupt``),版本漂移 fail-fast。 """ - def __init__(self, config: Any = None) -> None: + def __init__(self, config: Any = None, *, proxy_observer: Any = None) -> None: try: from openai_codex import ( # type: ignore[import-not-found] AsyncCodex, @@ -156,14 +221,235 @@ def __init__(self, config: Any = None) -> None: ) # AsyncCodex 0.144.4 only accepts one CodexConfig positional/keyword. - config, self._proxy = self._maybe_apply_proxy(config) + config, self._proxy = self._maybe_apply_proxy( + config, + proxy_observer=proxy_observer, + ) self._codex = AsyncCodex(config=config) self.sdk_version = sdk_version self._threads: dict[str, Any] = {} # thread_id -> AsyncThread self._active_handles: dict[str, Any] = {} # thread_id -> 活跃 AsyncTurnHandle + self._goal_states: dict[str, Any] = {} + self._approval_queues: dict[str, queue.Queue[Any]] = {} + self._pending_approvals: dict[str, _PendingApproval] = {} + self._pending_interactions: dict[str, _PendingInteraction] = {} + self._approval_lock = threading.Lock() + self._install_approval_bridge() + + def _install_approval_bridge(self) -> None: + """Replace the SDK's unconditional accept handler with a HITL bridge. + + ``openai-codex==0.144.4`` exposes approval callbacks only on its sync + JSON-RPC client. The public ``AsyncCodex`` wrapper owns that client, so + this pinned compatibility seam is validated eagerly instead of silently + auto-accepting tool and file changes. + """ + + async_client = getattr(self._codex, "_client", None) + sync_client = getattr(async_client, "_sync", None) + if sync_client is None or not hasattr(sync_client, "_approval_handler"): + raise RuntimeError( + f"openai-codex {self.sdk_version} 不支持交互式审批桥接(内部 API 不兼容)" + ) + sync_client._approval_handler = self._handle_server_request + + def _active_request_queue(self, raw: dict[str, Any]) -> tuple[str, queue.Queue[Any] | None]: + thread_id = str(raw.get("threadId") or raw.get("thread_id") or "") + if not thread_id: + with self._approval_lock: + candidates = list(self._approval_queues) + if len(candidates) == 1: + thread_id = candidates[0] + with self._approval_lock: + return thread_id, self._approval_queues.get(thread_id) + + def _handle_server_request( + self, + method: str, + params: dict[str, Any] | None, + ) -> dict[str, Any]: + if method in { + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + }: + return self._handle_approval_request(method, params) + if method == "item/tool/requestUserInput": + return self._handle_user_input_request(method, params) + return {} + + def _handle_approval_request( + self, + method: str, + params: dict[str, Any] | None, + ) -> dict[str, Any]: + raw = dict(params or {}) + if method not in { + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + }: + return {} + thread_id, approval_queue = self._active_request_queue(raw) + approval_id = str( + raw.get("itemId") or raw.get("item_id") or raw.get("id") or f"approval_{uuid4().hex}" + ) + pending = _PendingApproval( + approval_id=approval_id, + thread_id=thread_id, + method=method, + params=raw, + ) + with self._approval_lock: + if approval_queue is None: + # Fail closed when a request cannot be associated with an + # active run; never fall back to the SDK's auto-accept default. + return {"decision": "decline"} + self._pending_approvals[approval_id] = pending + approval_queue.put( + { + "method": "item/approval/requested", + "params": { + "id": approval_id, + "threadId": thread_id, + "kind": ( + "command" + if method == "item/commandExecution/requestApproval" + else "file_change" + ), + "detail": raw, + }, + } + ) + pending.resolved.wait() + with self._approval_lock: + self._pending_approvals.pop(approval_id, None) + return pending.response or {"decision": "decline"} + + def _handle_user_input_request( + self, + method: str, + params: dict[str, Any] | None, + ) -> dict[str, Any]: + raw = dict(params or {}) + thread_id, request_queue = self._active_request_queue(raw) + interaction_id = str( + raw.get("itemId") + or raw.get("item_id") + or raw.get("requestId") + or raw.get("request_id") + or raw.get("id") + or f"input_{uuid4().hex}" + ) + pending = _PendingInteraction( + interaction_id=interaction_id, + thread_id=thread_id, + method=method, + params=raw, + ) + if request_queue is None: + return {"answers": {}} + + surface_id = f"input-{interaction_id}" + components: list[dict[str, Any]] = [] + question_components: list[dict[str, Any]] = [] + input_schema: dict[str, Any] = {"type": "object", "properties": {}} + questions = raw.get("questions") if isinstance(raw.get("questions"), list) else [] + for index, raw_question in enumerate(questions): + if not isinstance(raw_question, dict): + continue + question_id = str(raw_question.get("id") or f"question_{index + 1}") + raw_options = raw_question.get("options") + options = ( + [dict(option) for option in raw_options if isinstance(option, dict)] + if isinstance(raw_options, list) + else [] + ) + multiple = bool( + raw_question.get("multiple") + or raw_question.get("isMultiple") + or raw_question.get("is_multiple") + or raw_question.get("isMultiSelect") + or raw_question.get("is_multi_select") + ) + question_components.append( + { + "id": question_id, + "component": "MultipleChoice", + "props": { + "name": question_id, + "label": str( + raw_question.get("header") + or raw_question.get("question") + or question_id + ), + "description": str(raw_question.get("question") or ""), + "options": options, + "multiple": multiple, + "allow_other": bool( + raw_question.get("isOther") or raw_question.get("is_other") + ), + "secret": bool( + raw_question.get("isSecret") or raw_question.get("is_secret") + ), + }, + } + ) + option_labels = [ + str(option.get("label") or "") for option in options if option.get("label") + ] + input_schema["properties"][question_id] = ( + { + "type": "array", + "items": {"type": "string", "enum": option_labels}, + } + if multiple + else {"type": "string", "enum": option_labels} + ) + components.append( + { + "id": "form", + "component": "Form", + "props": {"title": "需要你的反馈", "submit_label": "提交"}, + "children": question_components, + } + ) + with self._approval_lock: + self._pending_interactions[interaction_id] = pending + request_queue.put( + { + "method": "a2ui/surface", + "params": { + "surface_id": surface_id, + "surface": { + "catalog_id": "https://a2ui.org/specification/v0_9/basic_catalog.json", + "components": components, + "data_model": {}, + }, + }, + } + ) + request_queue.put( + { + "method": "a2ui/interaction", + "params": { + "surface_id": surface_id, + "interaction_id": interaction_id, + "kind": "form", + "input_schema": input_schema, + "is_blocking": bool(raw.get("isBlocking", raw.get("is_blocking", True))), + }, + } + ) + pending.resolved.wait() + with self._approval_lock: + self._pending_interactions.pop(interaction_id, None) + return pending.response or {"answers": {}} @staticmethod - def _maybe_apply_proxy(config: Any) -> tuple[Any, Any]: + def _maybe_apply_proxy( + config: Any, + *, + proxy_observer: Any = None, + ) -> tuple[Any, Any]: """codex 代理启用:**智能探测 fallback(带显式覆盖)**。 - ``KSADK_CODEX_USE_PROXY=1`` → 强制开代理;``=0`` → 强制直连(可人工覆盖误判)。 @@ -177,28 +463,39 @@ def _maybe_apply_proxy(config: Any) -> tuple[Any, Any]: 返回 (新 config, ProxyServer | None)。staticmethod 便于单测。 """ - env_val = os.environ.get("KSADK_CODEX_USE_PROXY") + runtime_env = {**os.environ, **(getattr(config, "env", None) or {})} + env_val = runtime_env.get("KSADK_CODEX_USE_PROXY") if env_val == "0": return config, None if env_val == "1": - return AsyncCodexClient._start_proxy_and_inject(config) + return AsyncCodexClient._start_proxy_and_inject( + config, + proxy_observer=proxy_observer, + ) # 未设:智能探测 base = ( - os.environ.get("KSADK_PROXY_UPSTREAM_BASE") - or os.environ.get("OPENAI_BASE_URL") - or os.environ.get("OPENAI_API_BASE") + runtime_env.get("KSADK_PROXY_UPSTREAM_BASE") + or runtime_env.get("OPENAI_BASE_URL") + or runtime_env.get("OPENAI_API_BASE") or "" ) if not base or _is_openai_official(base): return config, None # OpenAI 官方:直连,不探测 - model = os.environ.get("OPENAI_MODEL_NAME") or os.environ.get("MODEL_NAME") or "" - key = os.environ.get("KSADK_PROXY_UPSTREAM_KEY") or os.environ.get("OPENAI_API_KEY") or "" + model = runtime_env.get("OPENAI_MODEL_NAME") or runtime_env.get("MODEL_NAME") or "" + key = runtime_env.get("KSADK_PROXY_UPSTREAM_KEY") or runtime_env.get("OPENAI_API_KEY") or "" if _probe_requires_proxy(model, base, key): - return AsyncCodexClient._start_proxy_and_inject(config) + return AsyncCodexClient._start_proxy_and_inject( + config, + proxy_observer=proxy_observer, + ) return config, None @staticmethod - def _start_proxy_and_inject(config: Any) -> tuple[Any, Any]: + def _start_proxy_and_inject( + config: Any, + *, + proxy_observer: Any = None, + ) -> tuple[Any, Any]: """起进程内 ProxyServer + 注入 codex provider(opt-in/探测判定走代理时)。""" import dataclasses @@ -210,18 +507,29 @@ def _start_proxy_and_inject(config: Any) -> tuple[Any, Any]: "KSADK_CODEX_USE_PROXY 与 CodexConfig.launch_args_override 互斥:" "代理注入靠 config_overrides,而 launch_args_override 会整体覆盖命令行" ) + runtime_env = {**os.environ, **(cfg.env or {})} upstream = ( - os.environ.get("KSADK_PROXY_UPSTREAM_BASE") - or os.environ.get("OPENAI_BASE_URL") - or os.environ.get("OPENAI_API_BASE") + runtime_env.get("KSADK_PROXY_UPSTREAM_BASE") + or runtime_env.get("OPENAI_BASE_URL") + or runtime_env.get("OPENAI_API_BASE") or "https://kspmas.ksyun.com/v1" ) upstream = _upgrade_http_to_https(upstream) api_key = ( - os.environ.get("KSADK_PROXY_UPSTREAM_KEY") or os.environ.get("OPENAI_API_KEY") or "" + runtime_env.get("KSADK_PROXY_UPSTREAM_KEY") or runtime_env.get("OPENAI_API_KEY") or "" ) token = secrets.token_hex(16) - proxy = ProxyServer(ProxyConfig(upstream_base=upstream, api_key=api_key, local_token=token)) + proxy = ProxyServer( + ProxyConfig( + upstream_base=upstream, + api_key=api_key, + local_token=token, + upstream_model=runtime_env.get("OPENAI_MODEL_NAME") + or runtime_env.get("MODEL_NAME") + or "", + event_callback=proxy_observer, + ) + ) proxy.start() overrides = list(cfg.config_overrides or ()) overrides += [ @@ -235,13 +543,24 @@ def _start_proxy_and_inject(config: Any) -> tuple[Any, Any]: "features.multi_agent=false", "features.multi_agent_v2=false", ] - env = dict(cfg.env or {}) + # The local proxy owns the upstream credential. The Codex child only + # receives a random loopback token, so prompt-driven commands cannot + # read the real provider key from their environment. + env = dict(runtime_env) + for secret_name in ( + "OPENAI_API_KEY", + "AGENTKIT_MODEL_API_KEY", + "KSADK_PROXY_UPSTREAM_KEY", + ): + env.pop(secret_name, None) env["KSADK_PROXY_TOKEN"] = token return dataclasses.replace(cfg, config_overrides=tuple(overrides), env=env), proxy - async def start_thread(self, config: Optional[dict[str, Any]] = None) -> str: - thread = await self._codex.thread_start(**self._thread_kwargs(config)) + if self._uses_manual_approval(config): + thread = await self._start_manual_thread(config) + else: + thread = await self._codex.thread_start(**self._thread_kwargs(config)) self._threads[thread.id] = thread return str(thread.id) @@ -251,13 +570,91 @@ async def resume_thread(self, thread_id: str, config: Optional[dict[str, Any]] = cached = self._threads.get(thread_id) if cached is not None: return str(cached.id) - kwargs = self._thread_kwargs(config) - # AsyncCodex.thread_resume 0.144.4 has no ephemeral parameter. - kwargs.pop("ephemeral", None) - thread = await self._codex.thread_resume(thread_id, **kwargs) + if self._uses_manual_approval(config): + thread = await self._resume_manual_thread(thread_id, config) + else: + kwargs = self._thread_kwargs(config) + # AsyncCodex.thread_resume 0.144.4 has no ephemeral parameter. + kwargs.pop("ephemeral", None) + thread = await self._codex.thread_resume(thread_id, **kwargs) self._threads[thread.id] = thread return str(thread.id) + @staticmethod + def _uses_manual_approval(config: Optional[dict[str, Any]]) -> bool: + return str((config or {}).get("approval_mode") or "").strip().lower() == "manual" + + async def _start_manual_thread(self, config: Optional[dict[str, Any]]) -> Any: + """Start a thread whose native approvals are reviewed by Studio users. + + ``openai-codex==0.144.4`` exposes ``ApprovalsReviewer.user`` on the + generated app-server contract but omits it from the public + ``ApprovalMode`` enum. Use that pinned wire contract explicitly rather + than falling back to ``auto_review``. + """ + + from openai_codex import AsyncThread # type: ignore[import-not-found] + from openai_codex._sandbox import _sandbox_mode # type: ignore[import-not-found] + from openai_codex.generated.v2_all import ( # type: ignore[import-not-found] + ApprovalsReviewer, + AskForApproval, + AskForApprovalValue, + ThreadStartParams, + ) + + kwargs = self._thread_kwargs(self._without_manual_approval(config)) + kwargs.pop("approval_mode", None) + sandbox = kwargs.pop("sandbox", None) + await self._codex._ensure_initialized() + started = await self._codex._client.thread_start( + ThreadStartParams( + approval_policy=AskForApproval(root=AskForApprovalValue.on_request), + approvals_reviewer=ApprovalsReviewer.user, + sandbox=_sandbox_mode(sandbox), + **kwargs, + ) + ) + return AsyncThread(self._codex, started.thread.id) + + async def _resume_manual_thread( + self, + thread_id: str, + config: Optional[dict[str, Any]], + ) -> Any: + from openai_codex import AsyncThread # type: ignore[import-not-found] + from openai_codex._sandbox import _sandbox_mode # type: ignore[import-not-found] + from openai_codex.generated.v2_all import ( # type: ignore[import-not-found] + ApprovalsReviewer, + AskForApproval, + AskForApprovalValue, + ThreadResumeParams, + ) + + kwargs = self._thread_kwargs(self._without_manual_approval(config)) + kwargs.pop("approval_mode", None) + kwargs.pop("ephemeral", None) + sandbox = kwargs.pop("sandbox", None) + await self._codex._ensure_initialized() + resumed = await self._codex._client.thread_resume( + thread_id, + ThreadResumeParams( + thread_id=thread_id, + approval_policy=AskForApproval(root=AskForApprovalValue.on_request), + approvals_reviewer=ApprovalsReviewer.user, + sandbox=_sandbox_mode(sandbox), + **kwargs, + ), + ) + return AsyncThread(self._codex, resumed.thread.id) + + @staticmethod + def _without_manual_approval( + config: Optional[dict[str, Any]], + ) -> dict[str, Any]: + result = dict(config or {}) + result.pop("approval_mode", None) + return result + def run_turn( self, thread_id: str, @@ -267,6 +664,15 @@ def run_turn( ) -> AsyncIterator[dict[str, Any]]: return self._run_turn_gen(thread_id, prompt, config) + def run_goal( + self, + thread_id: str, + objective: str, + *, + config: Optional[dict[str, Any]] = None, + ) -> AsyncIterator[dict[str, Any]]: + return self._run_goal_gen(thread_id, objective, config) + async def _run_turn_gen( self, thread_id: str, prompt: Any, config: Optional[dict[str, Any]] ) -> AsyncIterator[dict[str, Any]]: @@ -275,15 +681,157 @@ async def _run_turn_gen( # 未显式 start/resume 的 thread_id:按 resume 语义接入真实后端。 await self.resume_thread(thread_id, config) thread = self._threads[thread_id] - handle = await thread.turn(self._coerce_input(prompt), **self._turn_kwargs(config)) + approval_queue: queue.Queue[Any] = queue.Queue() + with self._approval_lock: + self._approval_queues[thread_id] = approval_queue + handle = await self._start_turn(thread, self._coerce_input(prompt), config) self._active_handles[thread_id] = handle + notifications = handle.stream() + notification_task: Any = None + approval_task: Any = None try: - async for notification in handle.stream(): - event = self._notification_to_event_dict(notification) - if event is not None: - yield event + while True: + if notification_task is None: + notification_task = asyncio.create_task(notifications.__anext__()) + if approval_task is None: + approval_task = asyncio.create_task(asyncio.to_thread(approval_queue.get)) + done, _pending = await asyncio.wait( + {notification_task, approval_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if approval_task in done: + approval_event = approval_task.result() + approval_task = None + if approval_event is not None: + yield approval_event + if notification_task in done: + try: + notification = notification_task.result() + except StopAsyncIteration: + break + notification_task = None + event = self._notification_to_event_dict(notification) + if event is not None: + yield event finally: + approval_queue.put(None) + for task in (notification_task, approval_task): + if task is not None and not task.done(): + task.cancel() + await asyncio.gather( + *(task for task in (notification_task, approval_task) if task is not None), + return_exceptions=True, + ) self._active_handles.pop(thread_id, None) + with self._approval_lock: + self._approval_queues.pop(thread_id, None) + + async def _run_goal_gen( + self, + thread_id: str, + objective: str, + config: Optional[dict[str, Any]], + ) -> AsyncIterator[dict[str, Any]]: + if thread_id not in self._threads: + await self.resume_thread(thread_id, config) + from openai_codex._goal import _AsyncGoalNotificationStream + + low_level = self._codex._client + approval_queue: queue.Queue[Any] = queue.Queue() + with self._approval_lock: + self._approval_queues[thread_id] = approval_queue + state, _logical_turn_id = await low_level.start_goal_operation(thread_id, objective) + self._goal_states[thread_id] = state + notifications = _AsyncGoalNotificationStream( + state=state, + next_notification=lambda: low_level.next_goal_notification(state), + unregister=lambda: low_level.unregister_goal_operation(state), + cancel_goal=lambda: low_level.cancel_goal_operation(state), + ) + notification_task: Any = None + approval_task: Any = None + try: + while True: + if notification_task is None: + notification_task = asyncio.create_task(notifications.__anext__()) + if approval_task is None: + approval_task = asyncio.create_task(asyncio.to_thread(approval_queue.get)) + done, _pending = await asyncio.wait( + {notification_task, approval_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if approval_task in done: + approval_event = approval_task.result() + approval_task = None + if approval_event is not None: + yield approval_event + if notification_task in done: + try: + notification = notification_task.result() + except StopAsyncIteration: + break + notification_task = None + event = self._notification_to_event_dict(notification) + if event is not None: + yield event + finally: + approval_queue.put(None) + for task in (notification_task, approval_task): + if task is not None and not task.done(): + task.cancel() + await asyncio.gather( + *(task for task in (notification_task, approval_task) if task is not None), + return_exceptions=True, + ) + await notifications.aclose() + self._goal_states.pop(thread_id, None) + with self._approval_lock: + self._approval_queues.pop(thread_id, None) + + async def _start_turn( + self, + thread: Any, + prompt: Any, + config: Optional[dict[str, Any]], + ) -> Any: + mode = str((config or {}).get("collaboration_mode") or "").strip().lower() + if not mode or mode == "default": + return await thread.turn(prompt, **self._turn_kwargs(config)) + if mode != "plan": + raise ValueError("collaboration_mode must be default or plan") + + # openai-codex 0.144.4 predates the public collaboration_mode argument, + # while the bundled app-server already accepts it. Keep this private + # compatibility seam small and covered by a wire-payload test. + from openai_codex import AsyncTurnHandle + from openai_codex._approval_mode import _approval_mode_override_settings + from openai_codex._inputs import _normalize_run_input, _to_wire_input + from openai_codex._sandbox import _sandbox_policy + from openai_codex.generated.v2_all import TurnStartParams + + turn_config = self._turn_kwargs(config) + approval_mode = turn_config.pop("approval_mode", None) + sandbox = turn_config.pop("sandbox", None) + approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) + wire_input = _to_wire_input(_normalize_run_input(prompt)) + params = TurnStartParams( + thread_id=thread.id, + input=wire_input, + approval_policy=approval_policy, + approvals_reviewer=approvals_reviewer, + sandbox_policy=_sandbox_policy(sandbox), + **turn_config, + ).model_dump(mode="json", by_alias=True, exclude_none=True) + params["collaborationMode"] = { + "mode": "plan", + "settings": { + "model": str((config or {}).get("model") or ""), + "reasoning_effort": None, + "developer_instructions": None, + }, + } + started = await self._codex._client.turn_start(thread.id, wire_input, params=params) + return AsyncTurnHandle(self._codex, thread.id, started.turn.id) async def interrupt_active_turn(self, thread_id: str) -> bool: handle = self._active_handles.get(thread_id) @@ -292,7 +840,85 @@ async def interrupt_active_turn(self, thread_id: str) -> bool: await handle.interrupt() return True + async def pause_goal(self, thread_id: str) -> bool: + state = self._goal_states.get(thread_id) + if state is None: + return False + await self._codex._client.pause_goal(thread_id) + return True + + async def cancel_goal(self, thread_id: str) -> bool: + state = self._goal_states.get(thread_id) + if state is None: + return False + await self._codex._client.cancel_goal_operation(state) + return True + + async def resolve_approval(self, approval_id: str, decision: str) -> bool: + normalized = { + "approve": "accept", + "accept": "accept", + "approve_session": "acceptForSession", + "accept_for_session": "acceptForSession", + "deny": "decline", + "decline": "decline", + "cancel": "cancel", + }.get(str(decision).strip().lower()) + if normalized is None: + raise ValueError("approval decision must be approve, approve_session, deny, or cancel") + with self._approval_lock: + pending = self._pending_approvals.get(approval_id) + if pending is None: + return False + pending.response = {"decision": normalized} + pending.resolved.set() + return True + + async def resolve_interaction( + self, + interaction_id: str, + data: dict[str, Any], + ) -> bool: + answers: dict[str, dict[str, list[str]]] = {} + for key, value in data.items(): + if key in {"decision", "name", "action"} or value is None: + continue + raw_answers = value if isinstance(value, list) else [value] + normalized = [str(answer) for answer in raw_answers if str(answer)] + answers[str(key)] = {"answers": normalized} + with self._approval_lock: + pending = self._pending_interactions.get(interaction_id) + if pending is None: + return False + pending.response = {"answers": answers} + pending.resolved.set() + return True + async def close(self) -> None: + approval_lock = getattr(self, "_approval_lock", None) + if approval_lock is None: + pending: list[_PendingApproval] = [] + else: + with approval_lock: + pending = list(getattr(self, "_pending_approvals", {}).values()) + for approval in pending: + approval.response = {"decision": "cancel"} + approval.resolved.set() + if approval_lock is None: + interactions: list[_PendingInteraction] = [] + else: + with approval_lock: + interactions = list(getattr(self, "_pending_interactions", {}).values()) + for interaction in interactions: + interaction.response = {"answers": {}} + interaction.resolved.set() + for thread_id, state in list(getattr(self, "_goal_states", {}).items()): + try: + await self._codex._client.cancel_goal_operation(state) + except Exception: + pass + finally: + self._goal_states.pop(thread_id, None) try: await self._codex.close() finally: @@ -386,7 +1012,15 @@ def _notification_to_event_dict(notification: Any) -> Optional[dict[str, Any]]: "item/agentMessage/delta", "item/autoApprovalReview/started", "item/autoApprovalReview/completed", + "thread/tokenUsage/updated", + "turn/started", + "turn/completed", "error", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "item/reasoning/summaryPartAdded", + "thread/goal/updated", + "thread/goal/cleared", } if method in supported_methods: return {"method": method, "params": params} diff --git a/ksadk/codex/phase.py b/ksadk/codex/phase.py index 4995ea38..f1cc361c 100644 --- a/ksadk/codex/phase.py +++ b/ksadk/codex/phase.py @@ -8,7 +8,7 @@ - 因此必须**按 itemId 追踪相位,按解析出的相位路由 delta**;**不从文本内容推断相位**, 不等 ``item/completed`` 才更新 UI。 - 映射到 RuntimeEvent schema 的 ``phase`` 字段(commentary/final_answer), - **相位翻译独立成模块,不揉进 CodexRuntime 主逻辑,不混入最终答案**。 + **相位翻译独立成模块,不揉进 CodexRuntimeAdapter 主逻辑,不混入最终答案**。 Codex 相位:``commentary`` / ``analysis`` 属过程解说(process),``final_answer`` 是 最终答案。映射到 RuntimeEvent:process → ``commentary``,final_answer → ``final_answer``。 diff --git a/ksadk/codex/runtime.py b/ksadk/codex/runtime.py index dbe50716..285e2e56 100644 --- a/ksadk/codex/runtime.py +++ b/ksadk/codex/runtime.py @@ -1,4 +1,4 @@ -"""CodexRuntime — 非 ADK 体系的第三验证样本,按 Wegent 重托管模式 (goal-09)。 +"""CodexRuntimeAdapter — 非 ADK 体系的第三验证样本 (goal-09)。 对执行生命周期负责(不做 veadk 式薄桥接),后端能力面对齐 ``openai-codex`` SDK 真实线程模型 (``thread_start``/``thread.turn``/``handle.stream``/``handle.interrupt``/``thread_resume``): @@ -32,6 +32,7 @@ CancelResult, CheckpointCapability, CheckpointDescriptor, + PauseResult, ResumePayload, ResumeTarget, RunHandle, @@ -41,6 +42,8 @@ logger = logging.getLogger(__name__) +_INTERRUPT_DRAIN_TIMEOUT_SECONDS = 5.0 + class _CodexAsBaseRuntime(BaseRuntime): """把 CodexClient 包装为 BaseRuntime(原生能力面)。""" @@ -50,7 +53,13 @@ def __init__(self, client: CodexClient) -> None: self.runtime_type = "codex" def native_capabilities(self) -> dict[str, Any]: - return {"Framework": "codex", "cancel": "thread", "resume": "thread_id"} + return { + "Framework": "codex", + "cancel": "thread", + "pause": "interrupt_then_resume_thread", + "resume": "thread_id", + "live_interaction": True, + } @dataclass @@ -62,9 +71,14 @@ class _CodexThread: pending_approvals: set[str] = field(default_factory=set) done: bool = False interrupted: bool = False + paused: bool = False + started_at: int | None = None + completed_at: int | None = None + duration_ms: int | None = None + goal_mode: bool = False -class CodexRuntime(RuntimeAdapter): +class CodexRuntimeAdapter(RuntimeAdapter): """Codex 的 RuntimeAdapter(重托管)。""" def __init__( @@ -79,6 +93,7 @@ def __init__( self._sandbox_read_only = sandbox_read_only self._turn_timeout_seconds = turn_timeout_seconds self._threads: dict[str, _CodexThread] = {} + self._requests: dict[str, StartRequest] = {} self._known_threads: set[str] = set() self._pending_cancels: set[str] = set() # 被杀/interrupt 的 session 不持久化(goal-09 契约 1)。 @@ -98,16 +113,27 @@ async def start(self, request: StartRequest) -> RunHandle: else: # 把 model + base_instructions 传给 codex thread(配置契约,见 plan C) thread_config: dict[str, Any] = {"sandbox_read_only": self._sandbox_read_only} + if request.config: + for key in ("sandbox", "approval_mode", "summary", "ephemeral"): + value = request.config.get(key) + if key == "ephemeral" and value is not None: + thread_config[key] = bool(value) + elif value: + thread_config[key] = value if request.model: thread_config["model"] = request.model base_instructions = request.config.get("base_instructions") if base_instructions: thread_config["base_instructions"] = base_instructions + cwd = request.config.get("cwd") + if cwd: + thread_config["cwd"] = str(cwd) thread_id = await self._client.start_thread(thread_config) self._known_threads.add(thread_id) thread = _CodexThread(thread_id=thread_id) thread.__dict__["_start_request"] = request self._threads[thread_id] = thread + self._requests[thread_id] = request return RunHandle( run_id=thread_id, session_id=request.session_id, @@ -135,21 +161,75 @@ async def cancel(self, handle: RunHandle) -> CancelResult: thread.pending_approvals.clear() # 真实中断:handle.interrupt() 停活跃 turn(真实 SDK 机制;无"杀进程"概念, # thread 由 codex 后端托管,interrupt 即终止当前 turn 的执行)。 - interrupted = await self._client.interrupt_active_turn(thread.thread_id) + # 先唤醒 Studio 的 stream;等待 request_user_input 时,中断 RPC + # 可能要等 turn 状态前进,否则取消接口与流会互相等待。 + thread.interrupt_event.set() + interrupted = ( + await self._client.cancel_goal(thread.thread_id) + if thread.goal_mode + else await self._client.interrupt_active_turn(thread.thread_id) + ) if not interrupted: # 无活跃 handle 可 interrupt(竞态:turn 刚好结束)→ 视为未在运行。 return CancelResult.NOT_RUNNING - thread.interrupt_event.set() # 被中断的 session 不持久化(goal-09 契约 1)。 self._do_not_persist.add(thread_id) thread.done = True self._threads.pop(thread_id, None) + self._requests.pop(thread_id, None) self._pending_cancels.discard(thread_id) return CancelResult.INTERRUPTED_ACTIVE_TURN except Exception: # noqa: BLE001 logger.exception("codex cancel thread %s 失败", thread_id) return CancelResult.FAILED + async def pause(self, handle: RunHandle) -> PauseResult: + thread = self._threads.get(handle.run_id) + if thread is None or thread.done or not thread.streaming: + return PauseResult.NOT_RUNNING + if thread.pending_approvals: + # A blocked native approval must be answered through the interaction + # card. Interrupting while the SDK reader waits on that approval can + # deadlock the JSON-RPC command channel. + return PauseResult.FAILED + try: + interrupted = ( + await self._client.pause_goal(thread.thread_id) + if thread.goal_mode + else await self._client.interrupt_active_turn(thread.thread_id) + ) + if not interrupted: + return PauseResult.NOT_RUNNING + thread.paused = True + # ``thread/goal/set(status=paused)`` only prevents the next + # continuation. Wake the adapter stream as well so the current + # physical goal turn becomes an honest resumable pause now. + thread.interrupt_event.set() + return PauseResult.PAUSED_ACTIVE_TURN + except Exception: # noqa: BLE001 + logger.exception("codex pause thread %s 失败", handle.run_id) + return PauseResult.FAILED + + async def submit(self, handle: RunHandle, payload: ResumePayload) -> None: + if not payload.call_id: + raise ValueError("Codex live submit requires call_id") + raw = payload.data if isinstance(payload.data, dict) else {"decision": payload.data} + if payload.kind == "approval_decision": + decision = str(raw.get("decision") or raw.get("name") or "") + resolved = await self._client.resolve_approval(payload.call_id, decision) + elif payload.kind == "hitl_answer": + resolved = await self._client.resolve_interaction( + payload.call_id, + {key: value for key, value in raw.items() if key != "decision"}, + ) + else: + raise ValueError("Codex live submit requires approval_decision or hitl_answer") + if not resolved: + raise ValueError(f"interaction {payload.call_id!r} is not pending") + thread = self._threads.get(handle.run_id) + if thread is not None: + thread.pending_approvals.discard(payload.call_id) + async def resume( self, handle: RunHandle, @@ -158,16 +238,32 @@ async def resume( ) -> RunHandle: # resume 用 thread id 语义(resume_thread_id),不套 ADK invocation 模型。 if target.kind != "thread_id": - raise ValueError(f"CodexRuntime resume 仅支持 thread_id 目标,得到 {target.kind!r}") + raise ValueError( + f"CodexRuntimeAdapter resume 仅支持 thread_id 目标,得到 {target.kind!r}" + ) if handle.run_id in self._do_not_persist: raise ValueError(f"thread {handle.run_id} 已被中断/杀进程,不持久化,不可 resume") self._pending_cancels.discard(handle.run_id) self._known_threads.add(target.id) thread = _CodexThread(thread_id=target.id) thread.__dict__["_resume"] = {"target": target, "payload": payload} + request = self._requests.get(handle.run_id) + if request is not None: + thread.__dict__["_request_config"] = request + if request is not None and str(request.config.get("goal_objective") or "").strip(): + # Restart the same native Goal operation on the persisted thread; + # its transcript remains in Codex, while the objective/config are + # needed to resume Goal rather than issuing a normal text turn. + thread.goal_mode = True self._threads[handle.run_id] = thread # 真实恢复:thread_resume 接入后端既有 thread(thread_id 语义,不套 ADK invocation)。 - await self._client.resume_thread(target.id, {"sandbox_read_only": self._sandbox_read_only}) + resume_config: dict[str, Any] = {"sandbox_read_only": self._sandbox_read_only} + if request is not None and request.config: + for key in ("sandbox", "approval_mode"): + value = request.config.get(key) + if value: + resume_config[key] = value + await self._client.resume_thread(target.id, resume_config) handle.native_ref["thread_id"] = target.id handle.native_ref["resume_thread_id"] = target.id handle.native_ref["resume_payload"] = payload.data if payload else None @@ -199,6 +295,7 @@ async def checkpoint(self, handle: RunHandle) -> CheckpointDescriptor: async def close(self, handle: RunHandle) -> None: thread = self._threads.pop(handle.run_id, None) + self._requests.pop(handle.run_id, None) if thread is not None: thread.interrupt_event.set() try: @@ -244,19 +341,35 @@ async def _stream_events(self, handle: RunHandle) -> AsyncIterator[RuntimeEvent] request = thread.__dict__.get("_start_request") resume_state = thread.__dict__.get("_resume") if request is not None: - prompt = request.input + prompt = _request_prompt(request) elif resume_state is not None: prompt = _resume_prompt(resume_state.get("payload")) else: prompt = "" + run_input = _build_run_input(request, prompt) thread.streaming = True thread.turn_id = thread.turn_id or f"turn_{thread.thread_id}" try: - async for event in self._map_codex_stream(handle, thread, tracker, prompt): + async for event in self._map_codex_stream(handle, thread, tracker, run_input): yield event # 正常结束(非 interrupt):补 RUN_COMPLETED(AGUI 投射器据此发 RunFinished success) if not thread.interrupted: - yield self._event(handle, EventType.RUN_COMPLETED, {"status": "completed"}) + completed_payload: dict[str, Any] = { + "status": "completed", + "source": "codex", + } + if thread.started_at is not None: + completed_payload["started_at"] = thread.started_at + if thread.completed_at is not None: + completed_payload["completed_at"] = thread.completed_at + if thread.duration_ms is not None: + completed_payload["duration_ms"] = thread.duration_ms + yield self._event(handle, EventType.RUN_COMPLETED, completed_payload) + except asyncio.CancelledError: + thread.interrupted = True + self._do_not_persist.add(handle.run_id) + await self._client.interrupt_active_turn(thread.thread_id) + raise except TimeoutError: self.last_cancel_dropped_approvals = set(thread.pending_approvals) thread.pending_approvals.clear() @@ -288,16 +401,42 @@ async def _map_codex_stream( tracker: CodexPhaseTracker, prompt: Any, ) -> AsyncIterator[RuntimeEvent]: - codex_gen = self._client.run_turn( - thread.thread_id, - prompt, - config={"sandbox_read_only": self._sandbox_read_only}, - ) + request = thread.__dict__.get("_start_request") or thread.__dict__.get("_request_config") + run_config: dict[str, Any] = {"sandbox_read_only": self._sandbox_read_only} + if request is not None and request.config: + for key in ("sandbox", "approval_mode", "summary", "collaboration_mode"): + value = request.config.get(key) + if key == "approval_mode" and value == "manual": + continue + if value: + run_config[key] = value + if request is not None and request.model: + run_config["model"] = request.model + goal_objective = str( + request.config.get("goal_objective") or "" + if request is not None and request.config + else "" + ).strip() + if goal_objective: + thread.goal_mode = True + codex_gen = self._client.run_goal( + thread.thread_id, + goal_objective, + config=run_config, + ) + else: + codex_gen = self._client.run_turn( + thread.thread_id, + prompt, + config=run_config, + ) deadline = ( asyncio.get_running_loop().time() + self._turn_timeout_seconds if self._turn_timeout_seconds is not None else None ) + chunk_task: asyncio.Task[Any] | None = None + interrupt_task: asyncio.Task[bool] | None = None try: while True: chunk_task = asyncio.ensure_future(_anext_or_stop(codex_gen)) @@ -321,16 +460,56 @@ async def _map_codex_stream( await self._client.close() await asyncio.gather(chunk_task, interrupt_task, return_exceptions=True) raise TimeoutError("codex turn timed out") - for task in pending: - task.cancel() if interrupt_task in done: - chunk_task.cancel() + # ``openai-codex`` implements ``next_turn_notification`` + # with ``asyncio.to_thread(queue.get)``. Cancelling that + # awaiter unregisters its queue while the worker thread is + # still blocked, so neither a later transport close nor + # event-loop teardown can wake it. After interrupting a + # turn, keep the queue registered and drain the SDK's + # terminal notifications instead. If the backend fails to + # finish promptly, close the transport *before* awaiting + # the waiter so MessageRouter.fail_all can release it. + drain_deadline = ( + asyncio.get_running_loop().time() + + _INTERRUPT_DRAIN_TIMEOUT_SECONDS + ) + while True: + drain_remaining = max( + 0.0, + drain_deadline - asyncio.get_running_loop().time(), + ) + drained, _ = await asyncio.wait( + {chunk_task}, + timeout=drain_remaining, + ) + if not drained: + await self._client.close() + await asyncio.gather(chunk_task, return_exceptions=True) + break + try: + drained_chunk = chunk_task.result() + except Exception: # noqa: BLE001 + break + if drained_chunk is _STREAM_STOP: + break + chunk_task = asyncio.ensure_future(_anext_or_stop(codex_gen)) + chunk_task = None thread.interrupted = True # AGUI 投射器对 RUN_INTERRUPTED 无兜底,必须显式发,否则 raise yield self._event( - handle, EventType.RUN_INTERRUPTED, {"status": "interrupted"} + handle, + EventType.RUN_INTERRUPTED, + { + "status": "paused" if thread.paused else "interrupted", + "reason": "user_pause" if thread.paused else "runtime_interrupt", + }, ) return + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) chunk = chunk_task.result() if chunk is _STREAM_STOP: return @@ -338,6 +517,12 @@ async def _map_codex_stream( if event is not None: yield event finally: + waiter_tasks = [task for task in (chunk_task, interrupt_task) if task is not None] + for task in waiter_tasks: + if not task.done(): + task.cancel() + if waiter_tasks: + await asyncio.gather(*waiter_tasks, return_exceptions=True) aclose = getattr(codex_gen, "aclose", None) if callable(aclose): try: @@ -357,12 +542,195 @@ def _codex_chunk_to_event( method = str(chunk.get("method") or chunk.get("type") or "") params = chunk.get("params") or chunk + if method == "error": + raw_error = params.get("error") if isinstance(params, dict) else None + error = raw_error if isinstance(raw_error, dict) else {} + message = str( + error.get("message") + or (params.get("message") if isinstance(params, dict) else "") + or raw_error + or "Codex runtime transport failed" + ) + if not bool(params.get("will_retry") or params.get("willRetry")) or "401" in message: + raise RuntimeError(message) + return None + + if method == "thread/tokenUsage/updated": + token_usage = params.get("token_usage") or params.get("tokenUsage") or {} + last = token_usage.get("last") if isinstance(token_usage, dict) else {} + if not isinstance(last, dict): + last = {} + return self._event( + handle, + EventType.USAGE_REPORTED, + { + "input_tokens": int(last.get("input_tokens", last.get("inputTokens", 0)) or 0), + "cached_tokens": int( + last.get("cached_input_tokens", last.get("cachedInputTokens", 0)) or 0 + ), + "output_tokens": int( + last.get("output_tokens", last.get("outputTokens", 0)) or 0 + ), + "reasoning_tokens": int( + last.get( + "reasoning_output_tokens", + last.get("reasoningOutputTokens", 0), + ) + or 0 + ), + "total_tokens": int(last.get("total_tokens", last.get("totalTokens", 0)) or 0), + "source": "codex", + }, + ) + if method == "thread/goal/updated": + goal = params.get("goal") if isinstance(params, dict) else {} + goal = goal if isinstance(goal, dict) else {} + status = str(goal.get("status") or "").lower() + if status in {"paused", "blocked", "usage_limited", "budget_limited"}: + thread.paused = status == "paused" + thread.interrupted = True + return self._event( + handle, + EventType.RUN_INTERRUPTED, + {"status": status, "reason": "goal_status", "goal": goal}, + ) + return self._event( + handle, + EventType.RUN_PROGRESS, + {"native_event": "goal.updated", "native_data": goal}, + ) + if method in {"turn/started", "turn/completed"}: + raw_turn = params.get("turn") + turn: dict[str, Any] = raw_turn if isinstance(raw_turn, dict) else {} + started_at = turn.get("started_at", turn.get("startedAt")) + completed_at = turn.get("completed_at", turn.get("completedAt")) + duration_ms = turn.get("duration_ms", turn.get("durationMs")) + if started_at is not None: + thread.started_at = int(started_at) + if completed_at is not None: + thread.completed_at = int(completed_at) + if duration_ms is not None: + thread.duration_ms = max(0, int(duration_ms)) + return None + + if method == "a2ui/surface": + surface_id = str(params.get("surface_id") or params.get("surfaceId") or "") + return self._event( + handle, + EventType.A2UI_SURFACE_BEGIN, + { + "surface_id": surface_id, + "surface": params.get("surface") + if isinstance(params.get("surface"), dict) + else {}, + }, + ) + if method == "a2ui/interaction": + interaction_id = str(params.get("interaction_id") or params.get("interactionId") or "") + if interaction_id: + thread.pending_approvals.add(interaction_id) + return self._event( + handle, + EventType.A2UI_INTERACTION, + { + "surface_id": str(params.get("surface_id") or params.get("surfaceId") or ""), + "interaction_id": interaction_id, + "kind": str(params.get("kind") or "form"), + "input_schema": params.get("input_schema") + if isinstance(params.get("input_schema"), dict) + else {}, + "is_blocking": bool(params.get("is_blocking", True)), + }, + ) + if method == "item/started": tracker.observe_item(params) + item = params.get("item") or params + if item.get("type") == "commandExecution": + call_id = str(item.get("id") or "") + return self._event( + handle, + EventType.TOOL_CALL_BEGIN, + { + "call_id": call_id, + "name": "codex.command", + "args": { + "command": str(item.get("command") or ""), + "cwd": str(item.get("cwd") or ""), + "command_actions": item.get("commandActions") + or item.get("command_actions") + or [], + }, + }, + ) + if item.get("type") == "mcpToolCall": + call_id = str(item.get("id") or "") + server = str(item.get("server") or "") + tool = str(item.get("tool") or "") + return self._event( + handle, + EventType.TOOL_CALL_BEGIN, + { + "call_id": call_id, + "name": f"mcp.{server}.{tool}" if server else f"mcp.{tool}", + "args": { + "server": server, + "tool": tool, + "arguments": item.get("arguments"), + }, + }, + ) return None if method == "item/completed": item = params.get("item") or params - if item.get("type") != "agentMessage": + item_type = item.get("type") + if item_type == "commandExecution": + tracker.forget_item(params) + call_id = str(item.get("id") or "") + return self._event( + handle, + EventType.TOOL_CALL_END, + { + "call_id": call_id, + "name": "codex.command", + "result": { + "status": str(item.get("status") or "completed"), + "exit_code": item.get("exitCode", item.get("exit_code")), + "duration_ms": item.get("durationMs", item.get("duration_ms")), + "output": str( + item.get("aggregatedOutput") or item.get("aggregated_output") or "" + ), + }, + }, + ) + if item_type == "mcpToolCall": + tracker.forget_item(params) + call_id = str(item.get("id") or "") + server = str(item.get("server") or "") + tool = str(item.get("tool") or "") + raw_result = item.get("result") + result_obj: dict[str, Any] = raw_result if isinstance(raw_result, dict) else {} + raw_error = item.get("error") + error_obj: dict[str, Any] = raw_error if isinstance(raw_error, dict) else {} + output = self._mcp_result_text(result_obj) + error_message = str(error_obj.get("message") or "") + if not output and error_message: + output = error_message + return self._event( + handle, + EventType.TOOL_CALL_END, + { + "call_id": call_id, + "name": f"mcp.{server}.{tool}" if server else f"mcp.{tool}", + "result": { + "status": str(item.get("status") or "completed"), + "duration_ms": item.get("durationMs", item.get("duration_ms")), + "output": output, + **({"error": error_message} if error_message else {}), + }, + }, + ) + if item_type != "agentMessage": tracker.forget_item(params) return None phase = tracker.runtime_phase_for_item(params) @@ -374,7 +742,7 @@ def _codex_chunk_to_event( {"text": text}, phase=phase or "final_answer", ) - if "delta" in method or method == "item/agentMessage/delta": + if "delta" in method or "Delta" in method or method == "item/agentMessage/delta": phase = tracker.runtime_phase_for_delta(params) delta = str(params.get("delta") or "") if not delta: @@ -392,9 +760,9 @@ def _codex_chunk_to_event( thread.pending_approvals.discard(review_id) return None if ( - "approval" in method + "approval" in method.lower() or "requestPermission" in method - or "approval" in str(chunk.get("type") or "") + or "approval" in str(chunk.get("type") or "").lower() ): call_id = str( params.get("id") or params.get("call_id") or params.get("requestId") or "" @@ -404,7 +772,14 @@ def _codex_chunk_to_event( return self._event( handle, EventType.APPROVAL_REQUESTED, - {"approval_id": call_id, "call_id": call_id, "kind": "tool", "detail": params}, + { + "approval_id": call_id, + "call_id": call_id, + "kind": str(params.get("kind") or "tool"), + "detail": params.get("detail") + if isinstance(params.get("detail"), dict) + else params, + }, ) return None @@ -416,17 +791,46 @@ def _event( *, phase: Optional[str] = None, ) -> RuntimeEvent: + request = self._requests.get(handle.run_id) return RuntimeEvent.create( event_type, - agent_id="codex", - user_id=str(handle.native_ref.get("user_id") or "user"), + agent_id=str(request.agent_id or "codex") if request is not None else "codex", + user_id=( + request.user_id + if request is not None + else str(handle.native_ref.get("user_id") or "user") + ), session_id=handle.session_id, - invocation_id=handle.run_id, + invocation_id=( + str(request.metadata.get("invocation_id") or handle.run_id) + if request is not None + else handle.run_id + ), seq_id=self._next_seq(), phase=phase, payload=payload, ) + @staticmethod + def _mcp_result_text(result: dict[str, Any]) -> str: + """把 MCP result.content 列表提取为可读文本;无文本时退化 structuredContent。""" + if not result: + return "" + content = result.get("content") + if isinstance(content, list): + texts = [ + str(item.get("text") or "") + for item in content + if isinstance(item, dict) and item.get("type") == "text" + ] + texts = [text for text in texts if text] + if texts: + return "\n".join(texts) + structured = result.get("structuredContent", result.get("structured_content")) + if structured is not None: + return json.dumps(structured, ensure_ascii=False) + return "" + _STREAM_STOP = object() @@ -446,4 +850,91 @@ def _resume_prompt(payload: Optional[ResumePayload]) -> Any: return json.dumps(payload.data, ensure_ascii=False, sort_keys=True) -__all__ = ["CodexRuntime"] +def _request_prompt(request: StartRequest) -> Any: + """Render canonical conversation history for a native Codex turn. + + 只传 user 消息历史 + 当前 input,不传 assistant 的完整回复(避免 codex 复述历史)。 + assistant 回复用简短摘要代替,仅维持对话结构。 + """ + # A resumed Codex thread already owns its transcript. Re-sending Studio's + # transport-neutral history would duplicate every prior turn after refresh. + if str(request.metadata.get("thread_id") or "").strip(): + return request.input + + conversation = request.conversation_preprocessing() + if conversation is None or not conversation.messages: + return request.input + + lines: list[str] = [] + for message in conversation.messages: + content = message.get("content") + if not isinstance(content, str): + continue + role = str(message.get("role") or "user").strip().lower() + if role == "assistant": + # 不传完整回复,只标注"已回复",避免 codex 复述 + summary = content[:80] + ("..." if len(content) > 80 else "") + lines.append(f"[上一轮已回复: {summary}]") + elif role == "user": + lines.append(f"User: {content}") + return "\n".join(lines) or request.input + + +def _build_run_input(request: Optional[StartRequest], prompt: Any) -> Any: + """Compose Codex skills and native text/image/mention turn input.""" + try: + from openai_codex import ImageInput, LocalImageInput, MentionInput, SkillInput, TextInput + except ImportError: + return prompt + skills: list[SkillInput] = [] + if request is not None: + raw = request.config.get("skills") if request.config else None + if isinstance(raw, list): + for item in raw: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + path = str(item.get("path") or "").strip() + if name and path: + skills.append(SkillInput(name=name, path=path)) + native_items: list[Any] = [] + raw_input = request.input if request is not None else prompt + if isinstance(raw_input, list): + text_replaced = False + for item in raw_input: + if not isinstance(item, dict): + continue + kind = str(item.get("type") or "") + if kind == "text": + text = str( + prompt + if not text_replaced and isinstance(prompt, str) + else item.get("text") or "" + ) + text_replaced = True + if text: + native_items.append(TextInput(text=text)) + elif kind == "image" and item.get("url"): + native_items.append(ImageInput(url=str(item["url"]))) + elif kind == "localImage" and item.get("path"): + native_items.append(LocalImageInput(path=str(item["path"]))) + elif kind == "mention" and item.get("path"): + native_items.append( + MentionInput( + name=str(item.get("name") or item["path"]), + path=str(item["path"]), + ) + ) + else: + text = prompt if isinstance(prompt, str) else str(prompt or "") + if text: + native_items.append(TextInput(text=text)) + combined = [*skills, *native_items] + if not combined: + return prompt + if len(combined) == 1 and isinstance(combined[0], TextInput) and not skills: + return combined[0].text + return combined + + +__all__ = ["CodexRuntimeAdapter"] diff --git a/ksadk/common/kop_client.py b/ksadk/common/kop_client.py new file mode 100644 index 00000000..44bee50e --- /dev/null +++ b/ksadk/common/kop_client.py @@ -0,0 +1,232 @@ +"""KOP (Kingsoft Open Protocol) client — 公共 AICP 控制面调用层。 + +skill / a2a / mcp 等运行时模块通过本层访问 agentengine-server 对外 Action +(KOP 协议:`{base}/?Action=xxx&Version=xxx` + AWS V4 签名),避免每个模块 +各自实现一遍鉴权 + URL 构造 + header 装配。 + +本层是同步(基于 requests + requests-aws4auth);async 调用方用 +``asyncio.to_thread`` 包裹。AICP 控制面是低频调用,同步足够。 +""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any, Mapping, Optional +from urllib.parse import urlsplit + +import requests + +from ksadk.common.auth import AWSV4Auth + +logger = logging.getLogger(__name__) + +DEFAULT_API_VERSION = "2024-06-12" +DEFAULT_REGION = "cn-beijing-6" +DEFAULT_SERVICE = "aicp" + +# 精确匹配 AICP 控制面域名(公网 + 内网 + internal),不接受用户自定义 URL 走签名。 +_AICP_HOSTS = { + "aicp.api.ksyun.com", + "aicp.inner.api.ksyun.com", + "aicp.internal.api.ksyun.com", +} + + +def is_kop_endpoint(base_url: str) -> bool: + """Return True only for exact AICP control-plane hosts. + + User-controlled URLs must never receive KOP signing headers. + """ + host = (urlsplit(base_url or "").hostname or "").lower() + return host in _AICP_HOSTS + + +class KOPError(RuntimeError): + """KOP action 失败(非 2xx 或 Code != 0/200)。""" + + def __init__(self, *, code: int, message: str, request_id: str = "", action: str = "") -> None: + super().__init__(f"KOP {action} failed: code={code} message={message}") + self.code = code + self.message = message + self.request_id = request_id + self.action = action + + +class KOPClient: + """AICP KOP 控制面调用客户端。 + + 凭证优先级:构造参数 > KSADK_*_ACCESS_KEY/SECRET_KEY > KSYUN_ACCESS_KEY/SECRET_KEY。 + base_url 为空时按 region 探测默认 AICP(inner 优先,回落 public)。 + 非 AICP base_url(如本地 server 直连)走普通 POST,不签名。 + """ + + def __init__( + self, + *, + base_url: Optional[str] = None, + access_key: Optional[str] = None, + secret_key: Optional[str] = None, + region: Optional[str] = None, + service: Optional[str] = None, + api_version: Optional[str] = None, + account_id: Optional[str] = None, + service_token: Optional[str] = None, + timeout: float = 15.0, + ) -> None: + self.base_url = (base_url or os.getenv("KSADK_A2A_SERVICE_URL") or "").strip().rstrip("/") + if not self.base_url: + inner = os.getenv("KSADK_A2A_SERVICE_ENDPOINT", "").strip() + if inner: + scheme = os.getenv("KSADK_A2A_SERVICE_SCHEME", "https").strip() or "https" + self.base_url = f"{scheme}://{inner}".rstrip("/") + if not self.base_url: + # 默认探测:内网 inner 优先(runtime 在集群内),回落公网 public。 + self.base_url = self._detect_default_base_url() + self.region = ( + region + or os.getenv("KSADK_A2A_SERVICE_REGION") + or os.getenv("KSYUN_REGION") + or DEFAULT_REGION + ).strip() + self.service = (service or DEFAULT_SERVICE).strip() + self.api_version = (api_version or DEFAULT_API_VERSION).strip() + self.account_id = ( + account_id + or os.getenv("KSADK_A2A_ACCOUNT_ID") + or os.getenv("KSYUN_ACCOUNT_ID") + or "" + ).strip() + self.service_token = ( + service_token or os.getenv("KSADK_A2A_SERVICE_TOKEN") or "" + ).strip() + self.timeout = timeout + self._kop_mode = is_kop_endpoint(self.base_url) + ak = ( + access_key + or os.getenv("KSADK_A2A_ACCESS_KEY") + or os.getenv("KSYUN_ACCESS_KEY") + or "" + ).strip() + sk = ( + secret_key + or os.getenv("KSADK_A2A_SECRET_KEY") + or os.getenv("KSYUN_SECRET_KEY") + or "" + ).strip() + self._auth = AWSV4Auth( + access_key_id=ak, + secret_access_key=sk, + region=self.region, + service=self.service, + ) + self._session: Optional[requests.Session] = None + + @property + def is_kop_mode(self) -> bool: + return self._kop_mode + + def _session_obj(self) -> requests.Session: + if self._session is None: + self._session = requests.Session() + return self._session + + def _headers(self, action: str) -> dict[str, str]: + import uuid + + headers = {"Accept": "application/json", "Content-Type": "application/json"} + if self.account_id: + headers["X-Ksc-Account-Id"] = self.account_id + if self.service_token: + headers["Authorization"] = f"Bearer {self.service_token}" + if self._kop_mode: + headers["Host"] = urlsplit(self.base_url).netloc + headers["X-Ksc-Request-Id"] = str(uuid.uuid4()) + headers["X-Ksc-Region"] = self.region + headers["X-Ksc-Source"] = "ksadk-runtime" + # 预发环境路由:AICP 据此把请求转发到预发 server(线上 KOP 默认路由到线上 server)。 + logical_region = (os.getenv("KSYUN_REGION") or "").strip().lower() + if logical_region == "pre-online": + headers["X-KSC-CUSTOM-SOURCE"] = os.getenv("AGENTENGINE_PRE_CUSTOM_SOURCE", "pre") + if action: + headers["X-Action"] = action + headers["X-Version"] = self.api_version + return headers + + def _build_url(self, action: str) -> str: + if self._kop_mode: + return f"{self.base_url}/?Action={action}&Version={self.api_version}" + return f"{self.base_url}/agentengine/api/v1/{action}" + + def post_action( + self, action: str, payload: Optional[Mapping[str, Any]] = None + ) -> dict[str, Any]: + """POST 一个 KOP Action,返回 Data 字段(已解包信封)。 + + 非KOP 模式走 ``/agentengine/api/v1/{action}``;KOP 模式走 ``?Action=&Version=`` + 签名。 + """ + url = self._build_url(action) + headers = self._headers(action) + body = json.dumps(dict(payload or {}), ensure_ascii=False) + auth = ( + self._auth.get_auth() + if self._kop_mode and self._auth.is_enabled and not self.service_token + else None + ) + try: + response = self._session_obj().post( + url, + data=body.encode("utf-8"), + headers=headers, + auth=auth, + timeout=self.timeout, + ) + except requests.RequestException as exc: + raise KOPError( + code=503, + message=f"KOP control plane unavailable: {exc}", + action=action, + ) from exc + if not 200 <= response.status_code < 300: + raise KOPError( + code=response.status_code, + message=f"KOP HTTP error: {response.text[:200]}", + action=action, + ) + try: + envelope = response.json() + except ValueError as exc: + raise KOPError( + code=response.status_code or 502, + message="non-JSON response", + action=action, + ) from exc + if not isinstance(envelope, dict): + raise KOPError( + code=response.status_code or 502, + message="response not an object", + action=action, + ) + code = int(envelope.get("Code") or 0) + if code and code != 200: + raise KOPError( + code=code, + message=str(envelope.get("Message") or "KOP error"), + request_id=str(envelope.get("RequestId") or ""), + action=action, + ) + data = envelope.get("Data") + return data if isinstance(data, dict) else {} + + def _detect_default_base_url(self) -> str: + """默认 AICP 地址探测:内网 inner 优先,失败回落公网 public。""" + inner = "http://aicp.inner.api.ksyun.com" + public = "https://aicp.api.ksyun.com" + try: + import socket + host = urlsplit(inner).hostname or "" + socket.create_connection((host, 80), timeout=1).close() + return inner + except OSError: + return public diff --git a/ksadk/configs/__init__.py b/ksadk/configs/__init__.py index d8762f19..81a99636 100644 --- a/ksadk/configs/__init__.py +++ b/ksadk/configs/__init__.py @@ -8,7 +8,7 @@ 环境变量优先级 (举例): Model: OPENAI_API_KEY > LLM_API_KEY > MODEL_API_KEY - Langfuse: LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST + Tracing: OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS Agent: AGENT_ID > AGENT_RUNTIME_ID 默认值: @@ -24,7 +24,6 @@ AgentConfig, CodeModeConfig, KingsoftCloudConfig, - LangfuseConfig, # 各配置类 ModelConfig, OTelConfig, @@ -44,7 +43,6 @@ "Settings", # 配置类 "ModelConfig", - "LangfuseConfig", "AgentConfig", "KingsoftCloudConfig", "CodeModeConfig", diff --git a/ksadk/configs/env_registry.py b/ksadk/configs/env_registry.py index 3d69114d..956c1d26 100644 --- a/ksadk/configs/env_registry.py +++ b/ksadk/configs/env_registry.py @@ -19,6 +19,35 @@ class EnvVarSpec: EnvVarSpec( "KSADK_ADK_SESSION_URL", "sessions", "ADK-native database session URL.", sensitive=True ), + EnvVarSpec( + "KSADK_A2A_AGENT_ID", + "a2a", + "Opaque registered A2A Agent id injected post-registration; " + "required to wire full inbound JSON-RPC in v2. v1 discovery-only card " + "does not depend on it.", + ), + EnvVarSpec( + "KSADK_A2A_ACCOUNT_ID", + "a2a", + "Runtime owner account id injected by the deploy layer (ar-* agent's account).", + ), + EnvVarSpec( + "KSADK_A2A_RUNTIME_ID", + "a2a", + "Hosted Agent runtime resource id (ar-*) injected by the deploy layer; " + "the v1 discovery-only card mounts whenever this is non-empty.", + ), + EnvVarSpec( + "KSADK_A2A_AGENT_NAME", + "a2a", + "AgentCard display name injected by the deploy layer; falls back to " + "AGENTENGINE_MANAGED_RUNTIME_NAME then to the agent id.", + ), + EnvVarSpec( + "KSADK_A2A_AGENT_VERSION", + "a2a", + "AgentCard business version injected by the deploy layer; defaults to 0.1.0.", + ), EnvVarSpec( "KSADK_A2A_CONTROL_PLANE_URL", "a2a", @@ -36,6 +65,27 @@ class EnvVarSpec: "SQLite path for durable A2A task-event delivery batches.", ".agentengine/a2a_event_outbox.sqlite3", ), + EnvVarSpec( + "KSADK_A2A_INTERNAL_BASE_URL", + "a2a", + "Internal HTTP(S) origin used as AgentCard base_url before the gateway rewrites it; " + "must be an absolute origin with no path/query/fragment.", + ), + EnvVarSpec( + "KSADK_A2A_SPACE_ID", + "a2a", + "Primary A2A Space id configured for this Runtime Agent.", + ), + EnvVarSpec( + "KSADK_A2A_SPACE_IDS", + "a2a", + "Compatibility JSON array of A2A Space ids configured for this Runtime Agent.", + ), + EnvVarSpec( + "KSADK_A2A_TENANT_ID", + "a2a", + "Runtime tenant id injected by the deploy layer; falls back to account id.", + ), EnvVarSpec( "KSADK_A2A_TOKEN_DIR", "a2a", @@ -43,9 +93,53 @@ class EnvVarSpec: "/var/run/secrets/agentengine/a2a", ), EnvVarSpec( - "KSADK_A2A_SPACE_IDS", + "KSADK_A2A_SERVICE_URL", + "a2a", + "A2A control plane service URL (KOP public API); auto-detected if unset.", + ), + EnvVarSpec( + "KSADK_A2A_SERVICE_TOKEN", + "a2a", + "Bearer token for A2A control plane service authentication.", + sensitive=True, + ), + EnvVarSpec( + "KSADK_A2A_SERVICE_ENDPOINT", + "a2a", + "A2A service endpoint hostname (used for auto-detection with scheme).", + ), + EnvVarSpec( + "KSADK_A2A_SERVICE_SCHEME", + "a2a", + "A2A service URL scheme (http/https) for auto-detection.", + ), + EnvVarSpec( + "KSADK_A2A_SERVICE_REGION", "a2a", - "JSON array of A2A Space ids configured for this Runtime Agent.", + "A2A service region for KOP signing.", + ), + EnvVarSpec( + "KSADK_A2A_ACCESS_KEY", + "a2a", + "A2A KOP access key for signing; falls back to KSYUN_ACCESS_KEY.", + sensitive=True, + ), + EnvVarSpec( + "KSADK_A2A_SECRET_KEY", + "a2a", + "A2A KOP secret key for signing; falls back to KSYUN_SECRET_KEY.", + sensitive=True, + ), + EnvVarSpec( + "KSADK_A2A_SERVICE", + "a2a", + "A2A KOP signing service name (default: aicp).", + ), + EnvVarSpec( + "KSADK_EVAL_JUDGE_API_KEY", + "eval", + "API key for the LLM Judge evaluation backend.", + sensitive=True, ), EnvVarSpec( "KSADK_A2UI_GENERATION_TIMEOUT_SECONDS", @@ -117,6 +211,47 @@ class EnvVarSpec: "Codex proxy override: 1 forces the local Responses-to-Chat proxy and " "0 forces direct mode.", ), + EnvVarSpec( + "KSADK_CODEX_SANDBOX", + "codex", + "Codex sandbox mode: read_only (default, no writes) / workspace_write " + "(write inside workspace) / full_access (write anywhere).", + ), + EnvVarSpec( + "KSADK_CODEX_APPROVAL", + "codex", + "Codex approval mode: deny_all (default for read_only) / auto_review " + "(auto-approve with review log).", + ), + EnvVarSpec( + "KSADK_CODEX_HOME", + "codex", + "Explicit Codex home directory override for the native runtime.", + ), + EnvVarSpec( + "KSADK_CODEX_ISOLATE_HOME", + "codex", + "Isolate native Codex state under the project workspace; set to 0 for debugging only.", + "1", + ), + EnvVarSpec( + "KSADK_STUDIO_NO_SECURITY", + "studio", + "Disable Studio loopback session and CSRF checks for controlled tests only.", + "0", + ), + EnvVarSpec( + "KSADK_STUDIO_SESSION_TOKEN", + "studio", + "Explicit local Studio browser session token; generated randomly when unset.", + sensitive=True, + ), + EnvVarSpec( + "KSADK_STUDIO_TRACE_CONTENT", + "studio", + "Persist Studio trace event content; set to 0 to retain metadata only.", + "1", + ), EnvVarSpec( "KSADK_COMMAND_", "sandbox", "Internal prefix for command policy environment controls." ), @@ -186,6 +321,12 @@ class EnvVarSpec: EnvVarSpec( "KSADK_KB_SECRET_KEY", "knowledge_base", "Knowledge-base API secret key.", sensitive=True ), + EnvVarSpec( + "KSADK_KB_SESSION_TOKEN", + "knowledge_base", + "Knowledge-base STS session token.", + sensitive=True, + ), EnvVarSpec("KSADK_KB_TOP_K", "knowledge_base", "Knowledge-base retrieval result count.", "5"), EnvVarSpec( "KSADK_LANGGRAPH_CHECKPOINT_DSN", @@ -224,6 +365,12 @@ class EnvVarSpec: EnvVarSpec( "KSADK_LTM_SECRET_KEY", "memory", "Long-term-memory API secret key.", sensitive=True ), + EnvVarSpec( + "KSADK_LTM_SESSION_TOKEN", + "memory", + "Long-term-memory STS session token.", + sensitive=True, + ), EnvVarSpec("KSADK_LTM_TOP_K", "memory", "Long-term-memory retrieval result count.", "5"), EnvVarSpec( "KSADK_MCP_RUNTIME_REQUIREMENTS", @@ -609,7 +756,7 @@ class EnvVarSpec: "KSADK_WEB_VERSION", "web", "Published KsADK Web npm version used for a reproducible wheel build.", - "0.3.0", + "0.3.1", ), EnvVarSpec( "KSADK_WORKING_SET_MAX_FILES", @@ -629,38 +776,11 @@ class EnvVarSpec: EnvVarSpec( "CLOUD_MONITOR_APP_KEY", "tracing", - "CloudMonitor AppKey for optional OTLP ingestion.", - sensitive=True, - ), - EnvVarSpec( - "CLOUD_MONITOR_LANGFUSE_ENABLED", - "tracing", - "Enable or disable the CloudMonitor Langfuse SDK callback; defaults to enabled " - "when keys and host are present.", - ), - EnvVarSpec( - "CLOUD_MONITOR_LANGFUSE_HOST", - "tracing", - "CloudMonitor AppMonitor Langfuse SDK host.", - ), - EnvVarSpec( - "CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY", - "tracing", - "CloudMonitor AppMonitor Langfuse public key returned by the platform.", + "Deprecated: CloudMonitor AppKey translated to Ksc-Appkey OTLP header only when " + "both CLOUD_MONITOR_OTLP_TRACES_HEADERS and CLOUD_MONITOR_OTLP_HEADERS are absent. " + "Server should inject OTLP headers directly.", sensitive=True, ), - EnvVarSpec( - "CLOUD_MONITOR_LANGFUSE_SECRET_KEY", - "tracing", - "CloudMonitor AppMonitor Langfuse secret key returned by the platform.", - sensitive=True, - ), - EnvVarSpec( - "CLOUD_MONITOR_OTLP_ENABLED", - "tracing", - "Enable or disable the CloudMonitor OTLP exporter; defaults to enabled when " - "endpoint and AppKey are present.", - ), EnvVarSpec( "CLOUD_MONITOR_OTLP_ENDPOINT", "tracing", @@ -670,7 +790,7 @@ class EnvVarSpec: EnvVarSpec( "CLOUD_MONITOR_OTLP_HEADERS", "tracing", - "Additional CloudMonitor OTLP HTTP headers, comma-separated and URL-encoded.", + "CloudMonitor OTLP HTTP headers including Ksc-Appkey, comma-separated and URL-encoded.", sensitive=True, ), EnvVarSpec( @@ -683,6 +803,12 @@ class EnvVarSpec: "tracing", "CloudMonitor OTLP HTTP traces endpoint; takes precedence over the generic endpoint.", ), + EnvVarSpec( + "CLOUD_MONITOR_OTLP_TRACES_HEADERS", + "tracing", + "CloudMonitor OTLP HTTP traces headers; takes precedence over generic headers.", + sensitive=True, + ), EnvVarSpec( "CLOUD_MONITOR_OTLP_TRACES_PROTOCOL", "tracing", @@ -726,6 +852,12 @@ class EnvVarSpec: "OpenTelemetry resource attributes in key=value comma-separated form.", ), EnvVarSpec("OTEL_SERVICE_NAME", "tracing", "OpenTelemetry service name."), + EnvVarSpec( + "OPENCLAW_CONFIG_PATCH_JSON", + "openclaw", + "OpenClaw configuration patch JSON supplied at deployment time.", + sensitive=True, + ), EnvVarSpec( "KSADK_OTLP_MAX_EXPORT_BATCH_SIZE", "tracing", @@ -737,6 +869,19 @@ class EnvVarSpec: ENV_VAR_REGISTRY: tuple[EnvVarSpec, ...] = tuple( sorted(_ENV_VAR_REGISTRY_ITEMS, key=lambda spec: spec.name) ) +_ENV_SENSITIVITY_BY_NAME = {spec.name: spec.sensitive for spec in ENV_VAR_REGISTRY} + + +def is_sensitive_env_var(name: str) -> bool: + normalized = str(name or "").strip().upper() + if _ENV_SENSITIVITY_BY_NAME.get(normalized, False): + return True + if normalized.startswith("OTEL_EXPORTER_OTLP_") and normalized.endswith("_HEADERS"): + return True + return any( + token in normalized + for token in ("KEY", "TOKEN", "SECRET", "PASSWORD", "AUTHORIZATION", "SIGNATURE") + ) def iter_env_vars() -> tuple[EnvVarSpec, ...]: diff --git a/ksadk/configs/global_config.py b/ksadk/configs/global_config.py index 9085edc3..b017ad23 100644 --- a/ksadk/configs/global_config.py +++ b/ksadk/configs/global_config.py @@ -44,7 +44,7 @@ "IDENTITY_CACHE", ], # 未来可扩展更多分组 - # "observability": ["LANGFUSE_PUBLIC_KEY", ...], + # "observability": ["OTEL_EXPORTER_OTLP_ENDPOINT", ...], # "plugins": {...}, } diff --git a/ksadk/configs/settings.py b/ksadk/configs/settings.py index 8df03f7d..358d982c 100644 --- a/ksadk/configs/settings.py +++ b/ksadk/configs/settings.py @@ -11,8 +11,8 @@ print(settings.model.api_key) print(settings.model.api_base) - # 访问 Langfuse 配置 - print(settings.langfuse.public_key) + # 访问标准 OpenTelemetry 配置 + print(settings.otel.otlp_endpoint) # 访问 Agent 运行时信息 print(settings.agent.agent_id) @@ -263,73 +263,6 @@ def to_dict(self) -> dict: return result -# ============================================================================= -# Langfuse 配置 -# ============================================================================= - - -@dataclass -class LangfuseConfig: - """Langfuse 可观测性配置 - - 标准环境变量 (Langfuse 官方): - LANGFUSE_PUBLIC_KEY - API Public Key - LANGFUSE_SECRET_KEY - API Secret Key - LANGFUSE_HOST - Langfuse 服务地址 - - Serverless 平台扩展: - LANGFUSE_PROJECT_ID - 项目 ID (区分用户) - LANGCHAIN_TRACING_V2 - 启用 LangChain 追踪 - """ - - @property - def public_key(self) -> Optional[str]: - return os.getenv("LANGFUSE_PUBLIC_KEY") - - @property - def secret_key(self) -> Optional[str]: - return os.getenv("LANGFUSE_SECRET_KEY") - - @property - def host(self) -> str: - return ( - _get_env( - "LANGFUSE_HOST", - "LANGFUSE_BASE_URL", - default="http://localhost:3000", - ) - or "http://localhost:3000" - ) - - @property - def project_id(self) -> Optional[str]: - """Langfuse 项目 ID (Serverless 平台扩展)""" - return os.getenv("LANGFUSE_PROJECT_ID") - - @property - def tracing_enabled(self) -> bool: - """是否启用 LangChain tracing""" - return os.getenv("LANGCHAIN_TRACING_V2", "").lower() == "true" - - @property - def is_enabled(self) -> bool: - """是否启用 Langfuse (有 public_key 就启用)""" - return bool(self.public_key) - - @property - def is_configured(self) -> bool: - """是否完整配置""" - return bool(self.public_key and self.secret_key) - - def to_dict(self) -> dict: - """转换为字典""" - return { - "public_key": self.public_key, - "secret_key": self.secret_key, - "host": self.host, - } - - # ============================================================================= # Agent 配置 # ============================================================================= @@ -438,39 +371,6 @@ def is_configured(self) -> bool: """是否有 Agent 信息""" return bool(self.agent_id or self.agent_name) - # ------------------ - # Langfuse 集成方法 - # ------------------ - - def to_langfuse_params(self) -> dict: - """转换为 Langfuse trace() 方法的原生参数""" - params: Dict[str, Any] = {} - if self.session_id: - params["session_id"] = self.session_id - if self.user_id: - params["user_id"] = self.user_id - if self.tags: - params["tags"] = self.tags - if self.version: - params["version"] = self.version - return params - - def to_langfuse_metadata(self) -> dict: - """转换为 Langfuse metadata 字段""" - metadata = {} - if self.agent_id: - metadata["agent_id"] = self.agent_id - if self.agent_name: - metadata["agent_name"] = self.agent_name - if self.tenant_id: - metadata["tenant_id"] = self.tenant_id - if self.environment: - metadata["environment"] = self.environment - if self.extra_metadata: - metadata.update(self.extra_metadata) - return metadata - - # ============================================================================= # 金山云配置 # ============================================================================= @@ -594,16 +494,15 @@ class Settings: if settings.model.is_configured: llm = LiteLLM(api_key=settings.model.api_key) - # Langfuse 配置 - if settings.langfuse.is_enabled: - setup_tracing(langfuse_config=settings.langfuse.to_dict()) + # 标准 OTLP 配置 + if settings.otel.is_enabled: + setup_tracing() # Agent 信息 print(settings.agent.agent_id) """ model: ModelConfig = field(default_factory=ModelConfig) - langfuse: LangfuseConfig = field(default_factory=LangfuseConfig) agent: AgentConfig = field(default_factory=AgentConfig) cloud: KingsoftCloudConfig = field(default_factory=KingsoftCloudConfig) code: CodeModeConfig = field(default_factory=CodeModeConfig) @@ -615,8 +514,6 @@ def summary(self) -> str: lines.append( f" Model: {'✅ Configured' if self.model.is_configured else '❌ Not configured'}" ) - lines.append(f" Langfuse: {'✅ Enabled' if self.langfuse.is_enabled else '⚪ Disabled'}") - agent_info = self.agent.agent_id or self.agent.agent_name lines.append(f" Agent: {'✅ ' + agent_info if agent_info else '⚪ No info'}") diff --git a/ksadk/conversations/__init__.py b/ksadk/conversations/__init__.py index d45ffc81..9b34e68b 100644 --- a/ksadk/conversations/__init__.py +++ b/ksadk/conversations/__init__.py @@ -1,97 +1,88 @@ +"""Side-effect-free public exports for conversation preparation and persistence.""" + from __future__ import annotations -from ksadk.conversations.attachments import ( - decode_inline_data, - resolve_attachment_storage_path, - resolve_uploads_dir, -) -from ksadk.conversations.context import ( - CANONICAL_EVENT_TYPES, - build_history_from_events, - build_request_history, - canonical_event_type, - compacted_until_seq_id, - extract_event_text, - extract_text_from_event_parts, - group_events_by_api_round, - project_model_messages, - summarize_event_groups, -) -from ksadk.conversations.normalize import ( - attachment_from_part, - attachment_prompt_text, - compact_attachment_for_session, - display_content_from_parts, - extract_user_input_from_parts, - normalize_kop_message_content, - normalize_kop_messages, - normalize_parts_content, - normalize_responses_input, -) -from ksadk.conversations.runtime import ( - CompactionPlan, - PreparedConversationTurn, - append_context_checkpoint_event, - append_conversation_event, - append_reasoning_event, - append_run_checkpoint_event, - append_run_resume_event, - append_run_status_event, - build_chat_completions_payload, - build_compaction_sse_event, - build_responses_payload, - build_run_input, - compact_conversation_history, - ensure_conversation_session, - extract_responses_resume_input, - invoke_conversation_once, - preview_auto_compaction, - prime_session_metadata_for_user_turn, - stream_conversation_turn, - stream_responses_conversation_turn, -) +from importlib import import_module +from typing import Any + + +def _exports(module: str, *names: str) -> dict[str, tuple[str, str]]: + return {name: (module, name) for name in names} + + +_EXPORTS = { + **_exports( + "ksadk.conversations.attachments", + "decode_inline_data", + "resolve_attachment_storage_path", + "resolve_uploads_dir", + ), + **_exports( + "ksadk.conversations.context", + "CANONICAL_EVENT_TYPES", + "build_history_from_events", + "build_request_history", + "canonical_event_type", + "compacted_until_seq_id", + "extract_event_text", + "extract_text_from_event_parts", + "group_events_by_api_round", + "project_model_messages", + "summarize_event_groups", + ), + **_exports( + "ksadk.conversations.normalize", + "attachment_from_part", + "attachment_prompt_text", + "compact_attachment_for_session", + "display_content_from_parts", + "extract_user_input_from_parts", + "normalize_kop_message_content", + "normalize_kop_messages", + "normalize_parts_content", + "normalize_responses_input", + ), + **_exports( + "ksadk.conversations.runtime", + "CompactionPlan", + "PreparedConversationTurn", + "append_context_checkpoint_event", + "append_conversation_event", + "append_reasoning_event", + "append_run_checkpoint_event", + "append_run_resume_event", + "append_run_status_event", + "build_chat_completions_payload", + "build_compaction_sse_event", + "build_responses_payload", + "build_run_input", + "compact_conversation_history", + "ensure_conversation_session", + "extract_responses_resume_input", + "invoke_conversation_once", + "preview_auto_compaction", + "prime_session_metadata_for_user_turn", + "stream_conversation_turn", + "stream_responses_conversation_turn", + ), +} + +__all__ = list(_EXPORTS) + + +def __getattr__(name: str) -> Any: + if name == "runtime": + value = import_module("ksadk.conversations.runtime") + globals()[name] = value + return value + try: + module_name, attribute = _EXPORTS[name] + except KeyError: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None + value = getattr(import_module(module_name), attribute) + globals()[name] = value + return value + -__all__ = [ - "CANONICAL_EVENT_TYPES", - "CompactionPlan", - "PreparedConversationTurn", - "append_conversation_event", - "append_context_checkpoint_event", - "append_reasoning_event", - "append_run_checkpoint_event", - "append_run_resume_event", - "append_run_status_event", - "attachment_from_part", - "attachment_prompt_text", - "build_chat_completions_payload", - "build_compaction_sse_event", - "build_history_from_events", - "build_request_history", - "build_responses_payload", - "build_run_input", - "canonical_event_type", - "compact_conversation_history", - "compact_attachment_for_session", - "compacted_until_seq_id", - "decode_inline_data", - "display_content_from_parts", - "ensure_conversation_session", - "extract_event_text", - "extract_text_from_event_parts", - "extract_user_input_from_parts", - "extract_responses_resume_input", - "group_events_by_api_round", - "invoke_conversation_once", - "prime_session_metadata_for_user_turn", - "normalize_kop_message_content", - "normalize_kop_messages", - "normalize_parts_content", - "normalize_responses_input", - "preview_auto_compaction", - "project_model_messages", - "resolve_attachment_storage_path", - "resolve_uploads_dir", - "stream_conversation_turn", - "stream_responses_conversation_turn", - "summarize_event_groups", -] +def __dir__() -> list[str]: + return sorted({*globals(), *__all__}) diff --git a/ksadk/conversations/runtime_input.py b/ksadk/conversations/runtime_input.py index 9059dbc7..8527b569 100644 --- a/ksadk/conversations/runtime_input.py +++ b/ksadk/conversations/runtime_input.py @@ -431,7 +431,15 @@ def _build_runner_request_payload( prepared.resume_input.get("checkpoint_metadata") or {} ) else: - payload["input"] = prepared.resume_input + resume_input = dict(prepared.resume_input) + gateway_approval_resume = bool( + resume_input.pop("_ksadk_gateway_approval_resume", False) + ) + if gateway_approval_resume and bool( + getattr(runner, "supports_gateway_approval_semantic_resume", False) + ): + resume_input["_ksadk_gateway_approval_resume"] = True + payload["input"] = resume_input payload["resume"] = True previous_response_id = prepared.request_metadata.get("previous_response_id") if previous_response_id: diff --git a/ksadk/conversations/runtime_observability.py b/ksadk/conversations/runtime_observability.py index bccc4a72..efdffdfc 100644 --- a/ksadk/conversations/runtime_observability.py +++ b/ksadk/conversations/runtime_observability.py @@ -94,13 +94,25 @@ def _responses_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any] total_tokens = normalized.get("total_tokens", input_tokens + output_tokens) payload = { "input_tokens": input_tokens, + "input_tokens_details": {"cached_tokens": 0}, "output_tokens": output_tokens, + "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": total_tokens, } - if isinstance(normalized.get("input_token_details"), Mapping): - payload["input_token_details"] = dict(normalized["input_token_details"]) - if isinstance(normalized.get("output_token_details"), Mapping): - payload["output_token_details"] = dict(normalized["output_token_details"]) + input_details = normalized.get("input_token_details") + if isinstance(input_details, Mapping): + cached_tokens = input_details.get( + "cached_tokens", + input_details.get("cached", input_details.get("cache_read", 0)), + ) + payload["input_tokens_details"]["cached_tokens"] = int(cached_tokens or 0) + output_details = normalized.get("output_token_details") + if isinstance(output_details, Mapping): + reasoning_tokens = output_details.get( + "reasoning_tokens", + output_details.get("reasoning", 0), + ) + payload["output_tokens_details"]["reasoning_tokens"] = int(reasoning_tokens or 0) return payload diff --git a/ksadk/conversations/runtime_preparation.py b/ksadk/conversations/runtime_preparation.py index 61697095..c7702824 100644 --- a/ksadk/conversations/runtime_preparation.py +++ b/ksadk/conversations/runtime_preparation.py @@ -253,6 +253,14 @@ async def build_run_input( existing_events=existing_events, ) effective_resume_input = tool_resume_input or normalized_resume_input + if tool_resume_input is not None: + # ToolGateway approvals complete after an otherwise normal tool + # call. Only LangGraph needs this private marker to restart a + # terminal graph semantically; runtime_input strips it elsewhere. + effective_resume_input = { + **tool_resume_input, + "_ksadk_gateway_approval_resume": True, + } del existing_events event_history = await service.get_events(resolved_session_id) history = build_history_from_events(event_history) diff --git a/ksadk/conversations/runtime_resume.py b/ksadk/conversations/runtime_resume.py index 6b3e8fc8..9df74f69 100644 --- a/ksadk/conversations/runtime_resume.py +++ b/ksadk/conversations/runtime_resume.py @@ -15,6 +15,7 @@ validate_run_mode, ) from ksadk.conversations.runtime_persistence import append_conversation_event +from ksadk.events.runtime_event import EventType from ksadk.sessions import SessionEvent from ksadk.tools.gateway import ( build_tool_receipt_idempotency_key, @@ -24,11 +25,7 @@ def _has_pending_approval(events: Sequence[SessionEvent]) -> bool: pending = 0 for event in events: - event_type = canonical_event_type( - event.event_type, - author=event.author, - role=str((event.content or {}).get("role") or ""), - ) + event_type = _approval_lifecycle_event_type(event) if event_type == "approval_request": pending += 1 elif event_type == "approval_response" and pending > 0: @@ -37,23 +34,17 @@ def _has_pending_approval(events: Sequence[SessionEvent]) -> bool: def _approval_request_id_from_event(event: SessionEvent) -> str: - metadata = event.metadata or {} - interrupt_info = metadata.get("interrupt_info") - if isinstance(interrupt_info, Mapping): - value = interrupt_info.get("approval_request_id") or interrupt_info.get("id") - if value: - return str(value) + interrupt_info = _approval_interrupt_info_from_event(event) + value = interrupt_info.get("approval_request_id") or interrupt_info.get("id") + if value: + return str(value) return str(event.id or "") def _pending_approval_events(events: Sequence[SessionEvent]) -> list[SessionEvent]: pending: list[SessionEvent] = [] for event in events: - event_type = canonical_event_type( - event.event_type, - author=event.author, - role=str((event.content or {}).get("role") or ""), - ) + event_type = _approval_lifecycle_event_type(event) if event_type == "approval_request": pending.append(event) continue @@ -79,17 +70,52 @@ def _pending_approval_events(events: Sequence[SessionEvent]) -> list[SessionEven def _approval_request_events(events: Sequence[SessionEvent]) -> list[SessionEvent]: return [ - event - for event in events - if canonical_event_type( - event.event_type, - author=event.author, - role=str((event.content or {}).get("role") or ""), - ) - == "approval_request" + event for event in events if _approval_lifecycle_event_type(event) == "approval_request" ] +def _approval_lifecycle_event_type(event: SessionEvent) -> str: + """Classify approvals stored as either legacy session or RuntimeEvent rows.""" + + if event.event_type == EventType.APPROVAL_REQUESTED: + return "approval_request" + if event.event_type == EventType.APPROVAL_RESOLVED: + return "approval_response" + return canonical_event_type( + event.event_type, + author=event.author, + role=str((event.content or {}).get("role") or ""), + ) + + +def _approval_interrupt_info_from_event(event: SessionEvent) -> dict[str, Any]: + """Read the approval payload without downgrading canonical RuntimeEvents. + + RuntimeExecutor persists ``approval.requested`` as a RuntimeEvent envelope, + whereas legacy runners write an ``approval_request`` SessionEvent. Resume + accepts both forms so the durable runtime record remains the source of truth. + """ + + metadata = event.metadata or {} + legacy_detail = metadata.get("interrupt_info") + if isinstance(legacy_detail, Mapping): + return dict(legacy_detail) + + content = event.content or {} + payload = content.get("payload") + if not isinstance(payload, Mapping): + return {} + raw_detail = payload.get("detail") + detail = dict(raw_detail) if isinstance(raw_detail, Mapping) else {} + approval_id = payload.get("approval_id") or payload.get("call_id") + if approval_id: + detail.setdefault("approval_request_id", approval_id) + detail.setdefault("id", approval_id) + if payload.get("call_id"): + detail.setdefault("run_id", payload.get("call_id")) + return detail + + def _approval_resume_run_mode( events: Sequence[SessionEvent], resume_input: Mapping[str, Any], @@ -167,11 +193,7 @@ def _approval_decision_from_resume(resume_input: Mapping[str, Any]) -> dict[str, def _consecutive_approval_denials_from_events(events: Sequence[SessionEvent]) -> int: denials = 0 for event in reversed(events): - event_type = canonical_event_type( - event.event_type, - author=event.author, - role=str((event.content or {}).get("role") or ""), - ) + event_type = _approval_lifecycle_event_type(event) if event_type != "approval_response": continue resume_input = (event.metadata or {}).get("resume_input") @@ -210,9 +232,7 @@ def _normalize_approval_resume_input( if matched_event is None: return normalized - interrupt_info = (matched_event.metadata or {}).get("interrupt_info") - if not isinstance(interrupt_info, Mapping): - return normalized + interrupt_info = _approval_interrupt_info_from_event(matched_event) if not (interrupt_info.get("tool_name") or normalized.get("tool_name")): return normalized diff --git a/ksadk/conversations/runtime_streaming.py b/ksadk/conversations/runtime_streaming.py index 82f6099e..a292c2ff 100644 --- a/ksadk/conversations/runtime_streaming.py +++ b/ksadk/conversations/runtime_streaming.py @@ -14,6 +14,9 @@ build_responses_payload, ) from ksadk.conversations.runtime_stream_events import _iter_conversation_turn_events +from ksadk.runtime.conversation_execution import iter_runtime_conversation_semantic_events +from ksadk.runtime.executor import RuntimeExecutor, RuntimeStartPreparation +from ksadk.runtime.launch import RuntimeLaunchContext async def stream_conversation_turn( @@ -38,7 +41,7 @@ async def stream_conversation_turn( run_mode: str = RUN_MODE_FOREGROUND, ) -> AsyncIterator[str]: """Legacy ksadk response SSE stream used by hosted chat and chat-completions.""" - async for event in _iter_conversation_turn_events( + events = _iter_conversation_turn_events( runner=runner, agent_id=agent_id, user_id=user_id, @@ -57,7 +60,75 @@ async def stream_conversation_turn( invocation_id=invocation_id, session_service_provider=session_service_provider, run_mode=run_mode, + ) + async for chunk in _stream_conversation_semantic_events( + events=events, + model=model, + session_id=session_id, + ): + yield chunk + + +async def stream_runtime_conversation_turn( + *, + executor: RuntimeExecutor, + launch_context: RuntimeLaunchContext, + agent_id: str, + user_id: str, + messages: Sequence[Dict[str, Any]], + session_id: Optional[str], + model: Optional[str], + model_metadata: Mapping[str, Any] | None = None, + model_options: Mapping[str, Any] | None = None, + state_delta: Optional[dict[str, Any]] = None, + instructions: Optional[str] = None, + request_metadata: Mapping[str, Any] | None = None, + custom_metadata: Mapping[str, Any] | None = None, + resume_input: Mapping[str, Any] | None = None, + account_id: str | None = None, + invocation_id: Optional[str] = None, + session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_FOREGROUND, + runtime_preparation: RuntimeStartPreparation | None = None, +) -> AsyncIterator[str]: + """Legacy hosted-chat SSE driven by the canonical RuntimeExecutor.""" + + events = iter_runtime_conversation_semantic_events( + executor=executor, + launch_context=launch_context, + agent_id=agent_id, + user_id=user_id, + messages=messages, + session_id=session_id, + model=model, + model_metadata=model_metadata, + model_options=model_options, + state_delta=state_delta, + instructions=instructions, + request_metadata=request_metadata, + custom_metadata=custom_metadata, + resume_input=resume_input, + account_id=account_id, + invocation_id=invocation_id, + session_service_provider=session_service_provider, + run_mode=run_mode, + runtime_preparation=runtime_preparation, + ) + async for chunk in _stream_conversation_semantic_events( + events=events, + model=model, + session_id=session_id, ): + yield chunk + + +async def _stream_conversation_semantic_events( + *, + events: AsyncIterator[dict[str, Any]], + model: Optional[str], + session_id: Optional[str], +) -> AsyncIterator[str]: + async for event in events: event_type = event.get("type") if event_type == "compaction": yield build_compaction_sse_event( @@ -160,6 +231,108 @@ async def stream_responses_conversation_turn( ) -> AsyncIterator[str]: """OpenAI Responses-style SSE stream.""" response_id = f"resp_{uuid.uuid4().hex}" + events = _iter_conversation_turn_events( + runner=runner, + agent_id=agent_id, + user_id=user_id, + session_id=session_id, + messages=messages, + model=model, + prepare_runner=prepare_runner, + model_metadata=model_metadata, + model_options=model_options, + state_delta=state_delta, + instructions=instructions, + request_metadata=request_metadata, + custom_metadata=custom_metadata, + resume_input=resume_input, + response_id=response_id, + account_id=account_id, + invocation_id=invocation_id, + session_service_provider=session_service_provider, + run_mode=run_mode, + ) + async for chunk in _stream_responses_semantic_events( + events=events, + model=model, + session_id=session_id, + custom_metadata=custom_metadata, + include_agentengine_metadata=include_agentengine_metadata, + response_id=response_id, + ): + yield chunk + + +async def stream_runtime_responses_conversation_turn( + *, + executor: RuntimeExecutor, + launch_context: RuntimeLaunchContext, + agent_id: str, + user_id: str, + messages: Sequence[Dict[str, Any]], + session_id: Optional[str], + model: Optional[str], + model_metadata: Mapping[str, Any] | None = None, + model_options: Mapping[str, Any] | None = None, + state_delta: Optional[dict[str, Any]] = None, + instructions: Optional[str] = None, + request_metadata: Mapping[str, Any] | None = None, + custom_metadata: Mapping[str, Any] | None = None, + include_agentengine_metadata: bool = False, + resume_input: Mapping[str, Any] | None = None, + account_id: str | None = None, + invocation_id: Optional[str] = None, + session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_FOREGROUND, + runtime_preparation: RuntimeStartPreparation | None = None, +) -> AsyncIterator[str]: + """OpenAI Responses SSE driven by the canonical RuntimeExecutor.""" + + response_id = f"resp_{uuid.uuid4().hex}" + events = iter_runtime_conversation_semantic_events( + executor=executor, + launch_context=launch_context, + agent_id=agent_id, + user_id=user_id, + messages=messages, + session_id=session_id, + model=model, + model_metadata=model_metadata, + model_options=model_options, + state_delta=state_delta, + instructions=instructions, + request_metadata=request_metadata, + custom_metadata=custom_metadata, + resume_input=resume_input, + response_id=response_id, + account_id=account_id, + invocation_id=invocation_id, + session_service_provider=session_service_provider, + run_mode=run_mode, + runtime_preparation=runtime_preparation, + ) + async for chunk in _stream_responses_semantic_events( + events=events, + model=model, + session_id=session_id, + custom_metadata=custom_metadata, + include_agentengine_metadata=include_agentengine_metadata, + response_id=response_id, + ): + yield chunk + + +async def _stream_responses_semantic_events( + *, + events: AsyncIterator[dict[str, Any]], + model: Optional[str], + session_id: Optional[str], + custom_metadata: Mapping[str, Any] | None, + include_agentengine_metadata: bool, + response_id: str, +) -> AsyncIterator[str]: + """Serialize one semantic event stream into the Responses wire format.""" + created_at = int(time.time()) response_metadata = dict(custom_metadata or {}) @@ -217,27 +390,7 @@ def _next_output_index() -> int: next_output_index += 1 return output_index - async for event in _iter_conversation_turn_events( - runner=runner, - agent_id=agent_id, - user_id=user_id, - session_id=session_id, - messages=messages, - model=model, - prepare_runner=prepare_runner, - model_metadata=model_metadata, - model_options=model_options, - state_delta=state_delta, - instructions=instructions, - request_metadata=request_metadata, - custom_metadata=custom_metadata, - resume_input=resume_input, - response_id=response_id, - account_id=account_id, - invocation_id=invocation_id, - session_service_provider=session_service_provider, - run_mode=run_mode, - ): + async for event in events: if include_agentengine_metadata: event_metadata = event.get("metadata") agentengine_metadata = ( diff --git a/ksadk/deployment/managed_runtime.py b/ksadk/deployment/managed_runtime.py new file mode 100644 index 00000000..bc73fd08 --- /dev/null +++ b/ksadk/deployment/managed_runtime.py @@ -0,0 +1,33 @@ +"""Local manifest assembly for server-managed runtime deployments.""" + +from __future__ import annotations + +from pathlib import Path + +from ksadk.builders.managed_runtime_builder import ManagedRuntimeBuilder +from ksadk.deployment.base import DeployTarget, PackageInfo + + +def build_managed_runtime_package( + package_info: PackageInfo, target: DeployTarget +) -> PackageInfo: + """Build the canonical manifest and retain its integrity digest for deploy.""" + + builder = ManagedRuntimeBuilder( + Path(package_info.project_dir), + runtime_version=str(target.extra.get("runtime_version") or ""), + ) + result = builder.build() + if not result.success: + raise RuntimeError(f"构建失败: {result.error_message}") + + package_info.metadata.update(result.metadata) + manifest_sha256 = str(result.metadata.get("manifest_sha256") or "").strip() + if manifest_sha256: + target.extra["manifest_sha256"] = manifest_sha256 + if result.artifact_path is not None: + package_info.metadata["managed_manifest_path"] = str(result.artifact_path) + return package_info + + +__all__ = ["build_managed_runtime_package"] diff --git a/ksadk/deployment/manager.py b/ksadk/deployment/manager.py index adb4a57f..d805a6e6 100644 --- a/ksadk/deployment/manager.py +++ b/ksadk/deployment/manager.py @@ -224,9 +224,9 @@ def _generate_entrypoint(self, detection_result: Any) -> str: except ImportError: pass -from ksadk.runners import create_runner from ksadk.detection import DetectionResult, FrameworkType -from ksadk.server import app, set_runner +from ksadk.runtime import RuntimeExecutor, RuntimeLaunchContext, build_default_runtime_registry +from ksadk.server import RuntimeAppConfig, configure_runtime_app, create_runtime_app import uvicorn # 检测结果 (部署时固化) @@ -238,12 +238,28 @@ def _generate_entrypoint(self, detection_result: Any) -> str: agent_variable="{detection_result.agent_variable}" ) -# 创建 Runner 并加载 Agent -runner = create_runner(detection_result, "/app") -runner.load_agent() - -# 设置 Runner 到 FastAPI app -set_runner(runner, loaded=True) +runtime_context = RuntimeLaunchContext( + runtime_type=detection_result.type.value, + project_dir="/app", + detection=detection_result, + config=dict(getattr(detection_result, "raw_config", None) or {{}}), +) +# managed A2A discovery-only card:KSADK_A2A_RUNTIME_ID 非空时挂 +# /.well-known/agent-card.json;注册前即可被 server 探测(a2a-runtime-inbound-wiring)。 +_managed_a2a_card = None +if os.environ.get("KSADK_A2A_RUNTIME_ID", "").strip(): + from ksadk.managed_a2a_card import build_managed_a2a_card_if_configured + + _managed_a2a_card = build_managed_a2a_card_if_configured() +app = create_runtime_app( + RuntimeAppConfig( + runtime_type=detection_result.type.value, + runtime_executor=RuntimeExecutor(build_default_runtime_registry()), + launch_context=runtime_context, + a2a=_managed_a2a_card, + ), + configure_runtime_app, +) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/ksadk/deployment/providers/serverless.py b/ksadk/deployment/providers/serverless.py index d4d16f99..3ace5dff 100644 --- a/ksadk/deployment/providers/serverless.py +++ b/ksadk/deployment/providers/serverless.py @@ -17,6 +17,7 @@ import yaml from ksadk.api import AgentEngineClient, DryRunExit +from ksadk.builders.base import BaseBuilder from ksadk.builders.code_builder import CodeBuilder from ksadk.builders.container_builder import ( ContainerBuilder, @@ -193,19 +194,25 @@ def _load_deploy_env_vars( ) -> tuple[Dict[str, str], bool, int]: """读取部署时注入到托管运行时的环境变量。 - 全局配置作为兜底,项目 .env 作为项目级覆盖;真实 .env 文件不会随 - Code/Container 制品打包,只通过 deploy payload 注入到 Pod 环境变量。 + 优先级: --env/--env-file (explicit) > shell env (转发白名单前缀) > 项目 .env > 全局配置。 + 真实 .env 文件不会随 Code/Container 制品打包,只通过 deploy payload 注入到 Pod 环境变量。 """ + shell_keys = set(os.environ) env_vars: Dict[str, str] = dict(get_env_from_global_config()) env_file = Path(project_dir) / ".env" project_env_count = 0 - for key, value in sorted(os.environ.items()): - if value and _should_forward_process_env(key): - env_vars.setdefault(key, value) if env_file.exists(): project_env = cls._load_project_env_vars(env_file) project_env_count = len(project_env) - env_vars.update(project_env) + # auto .env 覆盖 global_config,但不覆盖 shell (shell 优先于 auto .env) + for key, value in project_env.items(): + if key not in shell_keys: + env_vars[key] = value + # shell 转发 (仅 KSADK_/OPENAI_/KSYUN_/E2B_ 前缀 + 白名单);shell 覆盖 auto .env 与全局配置 + for key, value in sorted(os.environ.items()): + if value and _should_forward_process_env(key): + env_vars[key] = value + # explicit --env/--env-file (显式 CLI 意图最高) env_vars.update(explicit_env_vars or {}) env_vars.setdefault("TZ", DEFAULT_RUNTIME_TIMEZONE) return env_vars, env_file.exists(), project_env_count @@ -387,23 +394,9 @@ async def build(self, package_info: PackageInfo, target: DeployTarget) -> Packag artifact_type = target.extra.get("artifact_type", "Code") if artifact_type == "ManagedRuntime": - # Declarative runtimes are delivered by Server -> Runtime Service as - # an inline manifest. Keep a local bundle only for inspection; do - # not acquire credentials or upload it to KS3. - from ksadk.builders.managed_runtime_builder import ManagedRuntimeBuilder - - builder = ManagedRuntimeBuilder( - Path(package_info.project_dir), - config=target.extra.copy(), - runtime_version=str(target.extra.get("runtime_version") or ""), - ) - build_result = builder.build() - if not build_result.success: - raise Exception(f"构建失败: {build_result.error_message}") - package_info.metadata.update(build_result.metadata) - if build_result.artifact_path is not None: - package_info.metadata["managed_manifest_path"] = str(build_result.artifact_path) - return package_info + from ksadk.deployment.managed_runtime import build_managed_runtime_package + + return build_managed_runtime_package(package_info, target) if artifact_type == "Code": # 1. 检查是否已有 KS3 路径 @@ -430,20 +423,24 @@ async def build(self, package_info: PackageInfo, target: DeployTarget) -> Packag builder_config["no_cache"] = no_cache builder_config["repackage"] = repackage + artifact_builder: BaseBuilder if artifact_type == "ManagedRuntime": from ksadk.builders.managed_runtime_builder import ManagedRuntimeBuilder - builder = ManagedRuntimeBuilder( + artifact_builder = ManagedRuntimeBuilder( Path(package_info.project_dir), config=builder_config, runtime_version=str(target.extra.get("runtime_version") or ""), ) else: # CodeBuilder 直接操作原始 project_dir。 - builder = CodeBuilder(Path(package_info.project_dir), config=builder_config) + artifact_builder = CodeBuilder( + Path(package_info.project_dir), + config=builder_config, + ) # 执行构建 - build_result = builder.build() + build_result = artifact_builder.build() if not build_result.success: raise Exception(f"构建失败: {build_result.error_message}") @@ -692,6 +689,7 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo runtime_config = { "name": str(target.extra.get("runtime_name") or "").strip(), "version": str(target.extra.get("runtime_version") or "").strip(), + "manifest_sha256": str(target.extra.get("manifest_sha256") or "").strip(), } missing = [key for key, value in runtime_config.items() if not value] if missing: @@ -707,7 +705,71 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo async with AgentEngineClient(region=target.region, dry_run=is_dry_run) as client: agent_exists = False - if existing_agent_id: + # 显式 --agent-id:优先于本地 state,用于状态丢失后重新关联已有 Agent。 + explicit_agent_id = (target.extra.get("agent_id") or "").strip() or None + if explicit_agent_id: + if existing_agent_id and existing_agent_id != explicit_agent_id: + click.secho( + f" ⚠️ --agent-id ({explicit_agent_id}) 与本地状态 " + f"({existing_agent_id}) 不一致,以 --agent-id 为准", + fg="yellow", + ) + if is_dry_run: + # DryRun 无法真实校验,假设存在并走更新路径 + existing_agent_id = explicit_agent_id + agent_exists = True + click.secho( + f" [Dry Run] 假设 Agent {explicit_agent_id} 存在", fg="cyan" + ) + else: + try: + detail = await client.get_agent( + explicit_agent_id, include_api_key=True + ) + except Exception as e: + return DeployResult( + status=DeployStatus.FAILED, + agent_id=explicit_agent_id, + message=( + f"❌ 指定的 Agent ID '{explicit_agent_id}' 不存在," + f"或当前凭证无权限访问。\n" + f" 详情: {e}\n" + " 👉 请确认 agent_id 正确,且当前 AK/SK / 账号" + "有该 Agent 的权限。" + ), + ) + + # 校验通过 → 关联并回填 state,走热更新 + qa = detail.get("quick_access", {}) or {} + basic = detail.get("basic", {}) or {} + recovered_state = local_state.copy() + recovered_state.update( + { + "agent_id": explicit_agent_id, + "name": basic.get("name") or package_info.name, + "region": target.region, + "endpoint": qa.get("public_endpoint"), + "updated_at": self._now_iso(), + } + ) + if qa.get("api_key"): + recovered_state["api_key"] = qa["api_key"] + # 去掉 None 值,避免覆盖掉旧的有效字段 + recovered_state = { + k: v for k, v in recovered_state.items() if v is not None + } + self._save_state(state_file, recovered_state) + local_state = recovered_state + + existing_agent_id = explicit_agent_id + agent_exists = True + click.secho( + f" 🔗 已通过 --agent-id 关联 Agent: {explicit_agent_id} " + f"(已回填 .agentengine.state)", + fg="green", + ) + + if existing_agent_id and not agent_exists: # 有本地状态 → 先检查服务器上是否存在 click.echo(f" 检测到本地状态: {existing_agent_id}") diff --git a/ksadk/evaluation/__init__.py b/ksadk/evaluation/__init__.py new file mode 100644 index 00000000..1a556f51 --- /dev/null +++ b/ksadk/evaluation/__init__.py @@ -0,0 +1,84 @@ +"""Public contracts and file loading for local Agent evaluation.""" + +from .a2a_adapter import A2ATargetAdapter, A2ATargetError +from .adapters import ( + EvaluationNotImplementedError, + TargetAdapter, + TargetAdapterError, + create_target_adapter, +) +from .contracts import ( + AssertionSpec, + AssertionType, + CaseRun, + DataPolicy, + EvalCase, + EvalRunReport, + EvalRunSpec, + EvalRunStatus, + EvalRunSummary, + EvalSetVersion, + EvalTurn, + EvaluationConfig, + EvaluationRequest, + MetricResult, + MetricStatus, + TargetKind, + TargetRef, + TargetRun, + TargetRunStatus, + TargetSnapshot, + TraceRef, + UsageSnapshot, +) +from .evalset import ( + EvalSetFormat, + EvalSetParseError, + identify_evalset_format, + load_evalset, + parse_evalset, +) +from .executor import EvaluationExecutionError, execute_evaluation +from .storage import EvaluationStorage, EvaluationStorageError +from .target import EvaluationTarget + +__all__ = [ + "AssertionSpec", + "AssertionType", + "A2ATargetAdapter", + "A2ATargetError", + "CaseRun", + "DataPolicy", + "EvalCase", + "EvalRunReport", + "EvalRunSummary", + "EvalRunSpec", + "EvalRunStatus", + "EvalSetFormat", + "EvalSetParseError", + "EvalSetVersion", + "EvalTurn", + "EvaluationConfig", + "EvaluationExecutionError", + "EvaluationNotImplementedError", + "EvaluationRequest", + "EvaluationStorage", + "EvaluationStorageError", + "EvaluationTarget", + "MetricResult", + "MetricStatus", + "TargetKind", + "TargetAdapter", + "TargetAdapterError", + "TargetRun", + "TargetRunStatus", + "TargetSnapshot", + "TargetRef", + "TraceRef", + "UsageSnapshot", + "identify_evalset_format", + "load_evalset", + "parse_evalset", + "execute_evaluation", + "create_target_adapter", +] diff --git a/ksadk/evaluation/a2a_adapter.py b/ksadk/evaluation/a2a_adapter.py new file mode 100644 index 00000000..9bf76458 --- /dev/null +++ b/ksadk/evaluation/a2a_adapter.py @@ -0,0 +1,444 @@ +"""A2A protocol adapter for evaluation targets.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import time +from contextlib import suppress +from dataclasses import dataclass, field +from urllib.parse import urlsplit, urlunsplit +from uuid import uuid4 + +import httpx +from a2a.client import A2ACardResolver, ClientConfig, create_client +from a2a.client.errors import A2AClientError, A2AClientTimeoutError, AgentCardResolutionError +from a2a.types import ( + AgentCard, + CancelTaskRequest, + Message, + Part, + Role, + SendMessageConfiguration, + SendMessageRequest, + StreamResponse, + TaskState, +) +from google.protobuf.json_format import MessageToDict + +from .adapters import TargetAdapterError +from .contracts import ( + EvalCase, + EvalRunSpec, + TargetKind, + TargetRef, + TargetRun, + TargetRunStatus, + TargetSnapshot, +) + +_SUPPORTED_BINDINGS = ["JSONRPC", "HTTP+JSON"] +_TERMINAL_FAILURES = { + TaskState.TASK_STATE_FAILED: ("A2A_TASK_FAILED", "远端 A2A Task 执行失败"), + TaskState.TASK_STATE_REJECTED: ("A2A_TASK_REJECTED", "远端 A2A Task 被拒绝"), + TaskState.TASK_STATE_AUTH_REQUIRED: ("A2A_AUTH_REQUIRED", "远端 A2A Task 需要鉴权"), + TaskState.TASK_STATE_INPUT_REQUIRED: ("A2A_INPUT_REQUIRED", "远端 A2A Task 需要额外输入"), +} + + +class A2ATargetError(TargetAdapterError): + """Safe, classified failure while resolving an A2A target.""" + + +@dataclass(frozen=True) +class _CardLocation: + base_url: str + relative_path: str | None + + +@dataclass +class _ResponseCollector: + state: int = TaskState.TASK_STATE_UNSPECIFIED + task_id: str | None = None + context_id: str | None = None + task_output: str = "" + status_output: str = "" + direct_output: str = "" + artifact_chunks: list[str] = field(default_factory=list) + + def observe(self, response: StreamResponse) -> None: + if response.task: + self.task_id = response.task.id or self.task_id + self.context_id = response.task.context_id or self.context_id + self.state = int(response.task.status.state) + self.task_output = _parts_text( + part + for artifact in response.task.artifacts + for part in artifact.parts + ) + self.status_output = _message_text(response.task.status.message) + + if response.status_update: + update = response.status_update + self.task_id = update.task_id or self.task_id + self.context_id = update.context_id or self.context_id + self.state = int(update.status.state) + self.status_output = _message_text(update.status.message) or self.status_output + + if response.artifact_update: + update = response.artifact_update + self.task_id = update.task_id or self.task_id + self.context_id = update.context_id or self.context_id + text = _parts_text(update.artifact.parts) + if text: + self.artifact_chunks.append(text) + + if response.message: + self.task_id = response.message.task_id or self.task_id + self.context_id = response.message.context_id or self.context_id + self.direct_output = _message_text(response.message) or self.direct_output + + @property + def output(self) -> str: + return ( + self.task_output + or "".join(self.artifact_chunks) + or self.status_output + or self.direct_output + ) + + +@dataclass(frozen=True) +class _TurnResult: + status: TargetRunStatus + output: str + task_id: str | None + context_id: str | None + task_state: int + error_code: str | None = None + error_message: str | None = None + + +def _new_http_client(*, headers: dict[str, str], timeout_seconds: int) -> httpx.AsyncClient: + return httpx.AsyncClient( + headers=headers, + timeout=httpx.Timeout(timeout_seconds), + follow_redirects=False, + ) + + +class A2ATargetAdapter: + """Execute evaluation cases through the official A2A 1.0 client.""" + + kind = TargetKind.A2A + + def __init__(self, *, timeout_seconds: int) -> None: + self._timeout_seconds = timeout_seconds + self._card: AgentCard | None = None + self._headers: dict[str, str] = {} + + async def snapshot(self, target: TargetRef) -> TargetSnapshot: + if target.kind is not self.kind: + raise A2ATargetError("A2A_TARGET_KIND_INVALID", "A2A adapter 收到非 A2A target") + + location = _card_location(target.locator) + headers = _credential_headers(target.credential_ref) + try: + async with _new_http_client( + headers=headers, + timeout_seconds=self._timeout_seconds, + ) as http_client: + resolver = A2ACardResolver( + httpx_client=http_client, + base_url=location.base_url, + ) + card = await resolver.get_agent_card( + relative_card_path=location.relative_path, + ) + client = await create_client( + agent=card, + client_config=ClientConfig( + httpx_client=http_client, + streaming=True, + supported_protocol_bindings=_SUPPORTED_BINDINGS, + ), + ) + await client.close() + except AgentCardResolutionError as exc: + raise A2ATargetError( + "A2A_CARD_UNAVAILABLE", + "无法读取或校验 A2A Agent Card", + ) from exc + except (A2AClientError, httpx.HTTPError, ValueError) as exc: + raise A2ATargetError( + "A2A_PROTOCOL_UNAVAILABLE", + "A2A Agent Card 没有可用的协议接口", + ) from exc + + digest = _agent_card_digest(card) + self._card = card + self._headers = headers + return TargetSnapshot( + kind=self.kind, + entrypoint=target.locator, + revision_digest=f"sha256:{digest}", + runtime="a2a/1.0", + metadata={ + "agentName": card.name, + "agentVersion": card.version, + "credentialRef": target.credential_ref, + "supportedInterfaces": [ + { + "protocolBinding": interface.protocol_binding, + "protocolVersion": interface.protocol_version, + } + for interface in card.supported_interfaces + ], + }, + ) + + async def run_case( + self, + spec: EvalRunSpec, + case: EvalCase, + *, + attempt: int, + ) -> TargetRun: + del spec, attempt + if self._card is None: + raise RuntimeError("A2A target must be snapshotted before execution") + + started_at = time.perf_counter() + task_ids: list[str] = [] + context_id: str | None = None + final_output = "" + last_state = TaskState.TASK_STATE_UNSPECIFIED + + try: + async with _new_http_client( + headers=self._headers, + timeout_seconds=self._timeout_seconds, + ) as http_client: + client = await create_client( + agent=self._card, + client_config=ClientConfig( + httpx_client=http_client, + streaming=True, + supported_protocol_bindings=_SUPPORTED_BINDINGS, + ), + ) + try: + for turn in case.turns: + result = await self._run_turn(client, turn.input, context_id) + context_id = result.context_id or context_id + last_state = result.task_state + if result.task_id: + task_ids.append(result.task_id) + if result.output: + final_output = result.output + if result.status != TargetRunStatus.PASSED: + return _target_run( + result.status, + started_at=started_at, + output=final_output, + task_ids=task_ids, + context_id=context_id, + task_state=last_state, + error_code=result.error_code, + error_message=result.error_message, + ) + finally: + await client.close() + except A2AClientTimeoutError: + return _target_run( + TargetRunStatus.ERROR, + started_at=started_at, + task_ids=task_ids, + context_id=context_id, + task_state=last_state, + error_code="A2A_NETWORK_TIMEOUT", + error_message="A2A 请求超时", + ) + except (A2AClientError, httpx.HTTPError, ValueError): + return _target_run( + TargetRunStatus.ERROR, + started_at=started_at, + task_ids=task_ids, + context_id=context_id, + task_state=last_state, + error_code="A2A_PROTOCOL_ERROR", + error_message="A2A 请求或响应不符合协议", + ) + + return _target_run( + TargetRunStatus.PASSED, + started_at=started_at, + output=final_output, + task_ids=task_ids, + context_id=context_id, + task_state=last_state, + ) + + async def _run_turn(self, client: object, text: str, context_id: str | None) -> _TurnResult: + collector = _ResponseCollector(context_id=context_id) + request = _build_send_request(text, context_id) + try: + async for response in client.send_message(request): # type: ignore[attr-defined] + collector.observe(response) + except asyncio.CancelledError: + if collector.task_id: + with suppress(Exception): + await client.cancel_task( # type: ignore[attr-defined] + CancelTaskRequest(id=collector.task_id) + ) + raise + + status, error_code, error_message = _task_result(collector) + return _TurnResult( + status=status, + output=collector.output, + task_id=collector.task_id, + context_id=collector.context_id, + task_state=collector.state, + error_code=error_code, + error_message=error_message, + ) + + +def _build_send_request( + text: str, + context_id: str | None, +) -> SendMessageRequest: + return SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + parts=[Part(text=text)], + message_id=f"eval-message-{uuid4().hex}", + context_id=context_id or "", + ), + configuration=SendMessageConfiguration(return_immediately=False), + ) + +def _card_location(locator: str) -> _CardLocation: + parsed = urlsplit(locator) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise A2ATargetError( + "A2A_URL_INVALID", + "A2A target 必须是有效的 http 或 https URL", + ) + if parsed.username or parsed.password or parsed.fragment: + raise A2ATargetError( + "A2A_URL_INVALID", + "A2A target URL 不能包含用户凭据或 fragment", + ) + + marker = "/.well-known/" + if marker not in parsed.path: + return _CardLocation(locator.rstrip("/"), None) + + base_path, card_name = parsed.path.split(marker, 1) + base_url = urlunsplit((parsed.scheme, parsed.netloc, base_path.rstrip("/"), "", "")) + relative_path = f".well-known/{card_name}" + if parsed.query: + relative_path = f"{relative_path}?{parsed.query}" + return _CardLocation(base_url, relative_path) + + +def _credential_headers(reference: str | None) -> dict[str, str]: + if not reference: + return {} + if not reference.startswith("env://"): + raise A2ATargetError( + "A2A_CREDENTIAL_REF_UNSUPPORTED", + "本地 A2A 评测当前只支持 env:// 鉴权引用", + ) + + name = reference.removeprefix("env://") + if not name or any(char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" for char in name): + raise A2ATargetError( + "A2A_CREDENTIAL_REF_INVALID", + "A2A env:// 鉴权引用格式无效", + ) + token = os.environ.get(name) + if not token: + raise A2ATargetError( + "A2A_CREDENTIAL_NOT_FOUND", + "A2A 鉴权引用尚未配置", + ) + if len(token) > 16_384 or token != token.strip() or any(ord(char) < 32 for char in token): + raise A2ATargetError( + "A2A_CREDENTIAL_INVALID", + "A2A 鉴权值格式无效", + ) + return {"Authorization": f"Bearer {token}"} + + +def _agent_card_digest(card: AgentCard) -> str: + payload = MessageToDict(card, preserving_proto_field_name=False) + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _message_text(message: Message) -> str: + return _parts_text(message.parts) if message else "" + + +def _parts_text(parts: object) -> str: + return "".join(part.text for part in parts if part.text) # type: ignore[attr-defined] + + +def _task_result( + collector: _ResponseCollector, +) -> tuple[TargetRunStatus, str | None, str | None]: + if collector.state == TaskState.TASK_STATE_CANCELED: + return TargetRunStatus.CANCELLED, "A2A_TASK_CANCELLED", "远端 A2A Task 已取消" + if collector.state in _TERMINAL_FAILURES: + code, message = _TERMINAL_FAILURES[collector.state] + return TargetRunStatus.FAILED, code, message + if collector.state in { + TaskState.TASK_STATE_SUBMITTED, + TaskState.TASK_STATE_WORKING, + }: + return ( + TargetRunStatus.ERROR, + "A2A_TASK_INCOMPLETE", + "A2A 响应结束时 Task 尚未进入终态", + ) + if collector.state == TaskState.TASK_STATE_COMPLETED or collector.output: + return TargetRunStatus.PASSED, None, None + return TargetRunStatus.UNAVAILABLE, "A2A_OUTPUT_UNAVAILABLE", "A2A 响应未提供文本结果" + + +def _target_run( + status: TargetRunStatus, + *, + started_at: float, + output: str = "", + task_ids: list[str], + context_id: str | None, + task_state: int, + error_code: str | None = None, + error_message: str | None = None, +) -> TargetRun: + metadata: dict[str, object] = {"remoteTaskIds": task_ids} + if context_id: + metadata["remoteContextId"] = context_id + if task_state != TaskState.TASK_STATE_UNSPECIFIED: + metadata["taskState"] = _task_state_name(task_state) + return TargetRun( + status=status, + output=output, + duration_ms=max(0, round((time.perf_counter() - started_at) * 1000)), + error_code=error_code, + error_message=error_message, + metadata=metadata, + ) + + +def _task_state_name(state: int) -> str: + try: + return TaskState.Name(state) + except ValueError: + return str(state) diff --git a/ksadk/evaluation/adapters.py b/ksadk/evaluation/adapters.py new file mode 100644 index 00000000..2f341f7c --- /dev/null +++ b/ksadk/evaluation/adapters.py @@ -0,0 +1,54 @@ +"""Target-kind routing for evaluation adapters.""" + +from __future__ import annotations + +from typing import Protocol + +from .contracts import EvalCase, EvalRunSpec, TargetKind, TargetRef, TargetRun, TargetSnapshot + + +class TargetAdapterError(RuntimeError): + """Classified failure raised by a protocol adapter.""" + + def __init__(self, code: str, message: str) -> None: + self.code = code + super().__init__(message) + + +class EvaluationNotImplementedError(NotImplementedError): + """Raised when a target kind has no evaluation adapter yet.""" + + +class TargetAdapter(Protocol): + """Protocol-specific execution behind the common target lifecycle.""" + + kind: TargetKind + + async def snapshot(self, target: TargetRef) -> TargetSnapshot: + """Resolve a target into an immutable snapshot.""" + + async def run_case( + self, spec: EvalRunSpec, case: EvalCase, *, attempt: int + ) -> TargetRun: + """Execute one case and return its normalized result.""" + + +def create_target_adapter(target: TargetRef, *, timeout_seconds: int) -> TargetAdapter: + """Route a target reference to its protocol adapter.""" + + if target.kind is TargetKind.A2A: + from .a2a_adapter import A2ATargetAdapter + + return A2ATargetAdapter(timeout_seconds=timeout_seconds) + raise EvaluationNotImplementedError( + f"{target.kind.value} target 的评测执行尚未实现;" + "当前可使用 --validate-only 校验评测集和参数" + ) + + +__all__ = [ + "EvaluationNotImplementedError", + "TargetAdapterError", + "TargetAdapter", + "create_target_adapter", +] diff --git a/ksadk/evaluation/contracts.py b/ksadk/evaluation/contracts.py new file mode 100644 index 00000000..34af33c6 --- /dev/null +++ b/ksadk/evaluation/contracts.py @@ -0,0 +1,409 @@ +"""Stable, serializable data objects shared by CLI and Studio evaluation.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +# --------------------------------------------------------------------------- +# Base model & helpers +# --------------------------------------------------------------------------- + + +def _to_camel_case(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.capitalize() for part in tail) + + +def _content_digest(payload: dict[str, Any]) -> str: + import hashlib + import json + + encoded = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +class EvaluationModel(BaseModel): + """Use camelCase on the wire while keeping Python fields snake_case.""" + + model_config = ConfigDict( + alias_generator=_to_camel_case, + populate_by_name=True, + extra="forbid", + ) + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +# Assertion kinds & data egress policy (configure how cases are asserted and +# what data may leave the local process). +class AssertionType(str, Enum): + """Deterministic assertion kinds supported by the first evaluation phase.""" + + RESPONSE_EQUALS = "response.equals" + RESPONSE_CONTAINS = "response.contains" + RESPONSE_NOT_CONTAINS = "response.notContains" + RESPONSE_JSON_SCHEMA = "response.jsonSchema" + RUNTIME_MAX_LATENCY_MS = "runtime.maxLatencyMs" + RUNTIME_MAX_INPUT_TOKENS = "runtime.maxInputTokens" + RUNTIME_MAX_OUTPUT_TOKENS = "runtime.maxOutputTokens" + TOOL_CALLED = "tool.called" + TOOL_NOT_CALLED = "tool.notCalled" + + +class DataPolicy(str, Enum): + """Controls what evaluation data may leave the local process.""" + + LOCAL_ONLY = "local_only" + METADATA_ONLY = "metadata_only" + REDACTED_TRACE = "redacted_trace" + FULL_TRACE = "full_trace" + + +# Lifecycle & outcome statuses (run-level, per-case target invocation, and +# per-metric verdicts). +class EvalRunStatus(str, Enum): + """Lifecycle status for an entire evaluation run.""" + + PENDING = "PENDING" + RUNNING = "RUNNING" + PASSED = "PASSED" + FAILED = "FAILED" + ERROR = "ERROR" + CANCELLED = "CANCELLED" + + +class TargetRunStatus(str, Enum): + """Terminal status returned by one target invocation.""" + + PASSED = "PASSED" + FAILED = "FAILED" + ERROR = "ERROR" + CANCELLED = "CANCELLED" + UNAVAILABLE = "UNAVAILABLE" + + +class MetricStatus(str, Enum): + """Outcome of a single deterministic metric.""" + + PASS = "PASS" + FAIL = "FAIL" + UNAVAILABLE = "UNAVAILABLE" + ERROR = "ERROR" + + +# --------------------------------------------------------------------------- +# Evaluation set & input cases +# --------------------------------------------------------------------------- + + +class EvalTurn(EvaluationModel): + """A single conversational turn within a multi-turn case.""" + + input: str = Field(min_length=1, max_length=32768) + expected_output: str | None = Field(default=None, max_length=32768) + expected_tools: list[dict[str, Any]] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class AssertionSpec(EvaluationModel): + """A single deterministic assertion with type-dependent value validation.""" + + type: AssertionType + value: Any + required: bool = True + + @model_validator(mode="after") + def validate_value(self) -> "AssertionSpec": + if self.type is AssertionType.RESPONSE_JSON_SCHEMA: + if not isinstance(self.value, dict): + raise ValueError("response.jsonSchema 的 value 必须是对象") + elif self.type.value.startswith("runtime."): + if isinstance(self.value, bool) or not isinstance(self.value, (int, float)): + raise ValueError(f"{self.type} 的 value 必须是非负数字") + if self.value < 0: + raise ValueError(f"{self.type} 的 value 必须是非负数字") + elif not isinstance(self.value, str): + raise ValueError(f"{self.type} 的 value 必须是字符串") + return self + + +class EvalCase(EvaluationModel): + """One evaluation case: ordered turns plus assertions to check.""" + + id: str = Field(min_length=1, max_length=128) + turns: list[EvalTurn] = Field(min_length=1) + assertions: list[AssertionSpec] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="before") + @classmethod + def normalize_single_input(cls, value: Any) -> Any: + if not isinstance(value, dict): + return value + data = dict(value) + if "input" in data and "turns" not in data: + turn = {"input": data.pop("input")} + for field in ("expected_output", "expectedOutput", "expected_tools", "expectedTools"): + if field in data: + turn[field] = data.pop(field) + data["turns"] = [turn] + return data + + @property + def input(self) -> str: + """Return the final turn for legacy single-input consumers.""" + + return self.turns[-1].input + + +class EvalSetVersion(EvaluationModel): + """A versioned, content-digested evaluation set of one or more cases.""" + + schema_version: Literal["ksadk.eval/v1"] = "ksadk.eval/v1" + name: str = Field(min_length=1, max_length=256) + cases: list[EvalCase] = Field(min_length=1) + metadata: dict[str, Any] = Field(default_factory=dict) + source_format: str = "native" + content_digest: str = "" + + @model_validator(mode="after") + def validate_and_digest(self) -> "EvalSetVersion": + ids = [case.id for case in self.cases] + if len(ids) != len(set(ids)): + raise ValueError("评测集中的 case id 必须唯一") + expected = self.compute_digest() + if self.content_digest and self.content_digest != expected: + raise ValueError("contentDigest 与规范化评测集内容不一致") + self.content_digest = expected + return self + + def compute_digest(self) -> str: + """Hash normalized cases, excluding transport format and the digest itself.""" + + payload = self.model_dump( + mode="json", + by_alias=False, + exclude={"content_digest", "source_format"}, + ) + return _content_digest(payload) + + +# --------------------------------------------------------------------------- +# Target identity & references +# --------------------------------------------------------------------------- + + +class TargetKind(str, Enum): + """Supported target categories; adapters own their implementation details.""" + + LOCAL_SOURCE = "local_source" + STUDIO_BUILD = "studio_build" + A2A = "a2a" + CODEX_WORKTREE = "codex_worktree" + + +class TargetSnapshot(EvaluationModel): + """Immutable target identity recorded with an evaluation run.""" + + kind: TargetKind + entrypoint: str = Field(min_length=1, max_length=1024) + revision_digest: str = Field(min_length=1, max_length=128) + runtime: str = Field(default="", max_length=128) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class TargetRef(EvaluationModel): + """Unresolved target supplied by CLI/Studio before an adapter snapshots it.""" + + kind: TargetKind + locator: str = Field(min_length=1, max_length=2048) + entrypoint: str | None = Field(default=None, min_length=1, max_length=1024) + credential_ref: str | None = Field(default=None, min_length=1, max_length=512) + profile: str | None = Field(default=None, min_length=1, max_length=256) + + +# --------------------------------------------------------------------------- +# Execution config & request / spec +# --------------------------------------------------------------------------- + + +class EvaluationConfig(EvaluationModel): + """Execution limits and evaluator selection shared by CLI and Studio.""" + + timeout_seconds: int = Field(default=120, ge=1, le=3600) + fail_fast: bool = False + evaluators: list[str] = Field(default_factory=list) + data_policy: DataPolicy = DataPolicy.LOCAL_ONLY + judge_model: str | None = Field(default=None, min_length=1, max_length=256) + judge_api_base: str | None = Field(default=None, min_length=1, max_length=2048) + judge_api_key_env: str = Field( + default="KSADK_EVAL_JUDGE_API_KEY", + pattern=r"^[A-Za-z_][A-Za-z0-9_]*$", + max_length=128, + ) + + +class EvaluationRequest(EvaluationModel): + """Validated input passed into the one public evaluation executor.""" + + evalset: EvalSetVersion + target: TargetRef + config: EvaluationConfig = Field(default_factory=EvaluationConfig) + report_dir: str | None = Field(default=None, min_length=1, max_length=2048) + + +class EvalRunSpec(EvaluationModel): + """Immutable execution plan after the target has been snapshotted.""" + + id: str = Field(min_length=1, max_length=128) + evalset: EvalSetVersion + target: TargetSnapshot + config: EvaluationConfig = Field(default_factory=EvaluationConfig) + environment_digest: str = Field(default="", max_length=128) + attempt: int = Field(default=1, ge=1) + + +# --------------------------------------------------------------------------- +# Run results: trace, usage, target run, metric results, case run, report +# --------------------------------------------------------------------------- + + +# Per-case adapter output: trace linkage, token usage, and the normalized +# invocation result returned by a target adapter. +class TraceRef(EvaluationModel): + """Queryable linkage to a recorded trace; at least one id required.""" + + run_id: str | None = None + trace_id: str | None = None + root_span_id: str | None = None + seq_id: int | None = Field(default=None, ge=1) + + @model_validator(mode="after") + def require_reference(self) -> "TraceRef": + if not any((self.run_id, self.trace_id, self.seq_id)): + raise ValueError("TraceRef 至少需要一个可查询 ID") + return self + + +class UsageSnapshot(EvaluationModel): + """Token usage for one invocation; zero unless the adapter reported it.""" + + input_tokens: int = Field(default=0, ge=0) + output_tokens: int = Field(default=0, ge=0) + total_tokens: int = Field(default=0, ge=0) + reported: bool = False + + +class TargetRun(EvaluationModel): + """Normalized result returned by a target adapter for one case.""" + + status: TargetRunStatus + output: str = "" + duration_ms: int | None = Field(default=None, ge=0) + usage: UsageSnapshot = Field(default_factory=UsageSnapshot) + error_code: str | None = None + error_message: str | None = None + trace_ref: TraceRef | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +# Evaluator output: per-metric verdicts, per-case aggregation, run-level +# summary counts, and the canonical persisted report. +class MetricResult(EvaluationModel): + """One deterministic evaluator result and its non-sensitive evidence.""" + + name: str = Field(min_length=1, max_length=128) + version: str = Field(default="v1", min_length=1, max_length=32) + status: MetricStatus + score: float | None = Field(default=None, ge=0, le=1) + required: bool = True + evidence: dict[str, Any] = Field(default_factory=dict) + + +class CaseRun(EvaluationModel): + """Target result and metric results for one case attempt.""" + + case_id: str = Field(min_length=1, max_length=128) + attempt: int = Field(default=1, ge=1) + target_run: TargetRun + metrics: list[MetricResult] = Field(default_factory=list) + + @property + def passed(self) -> bool: + return self.target_run.status is TargetRunStatus.PASSED and all( + metric.status is MetricStatus.PASS for metric in self.metrics if metric.required + ) + + +class EvalRunSummary(EvaluationModel): + """Derived terminal counts; callers must not construct their own summary.""" + + total_cases: int = Field(default=0, ge=0) + passed_cases: int = Field(default=0, ge=0) + failed_cases: int = Field(default=0, ge=0) + unavailable_cases: int = Field(default=0, ge=0) + error_cases: int = Field(default=0, ge=0) + cancelled_cases: int = Field(default=0, ge=0) + + +class EvalRunReport(EvaluationModel): + """Canonical persisted result consumed by CLI, Studio, and future sync.""" + + schema_version: Literal["ksadk.eval.report/v1"] = "ksadk.eval.report/v1" + spec: EvalRunSpec + status: EvalRunStatus + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + case_runs: list[CaseRun] = Field(default_factory=list) + summary: EvalRunSummary = Field(default_factory=EvalRunSummary) + report_digest: str = "" + + @model_validator(mode="after") + def validate_case_ids(self) -> "EvalRunReport": + case_ids = [case.case_id for case in self.case_runs] + if len(case_ids) != len(set(case_ids)): + raise ValueError("一次评测中 case_id 不能重复") + self.summary = self._summarize_cases() + expected = self.compute_digest() + if self.report_digest and self.report_digest != expected: + raise ValueError("reportDigest 与规范化报告内容不一致") + self.report_digest = expected + return self + + def _summarize_cases(self) -> EvalRunSummary: + counts = EvalRunSummary(total_cases=len(self.case_runs)) + for case_run in self.case_runs: + if case_run.target_run.status is TargetRunStatus.ERROR or any( + metric.required and metric.status is MetricStatus.ERROR + for metric in case_run.metrics + ): + counts.error_cases += 1 + elif case_run.target_run.status is TargetRunStatus.CANCELLED: + counts.cancelled_cases += 1 + elif case_run.target_run.status is TargetRunStatus.UNAVAILABLE or any( + metric.required and metric.status is MetricStatus.UNAVAILABLE + for metric in case_run.metrics + ): + counts.unavailable_cases += 1 + elif case_run.passed: + counts.passed_cases += 1 + else: + counts.failed_cases += 1 + return counts + + def compute_digest(self) -> str: + """Return a stable digest excluding the digest field itself.""" + + payload = self.model_dump(mode="json", by_alias=False, exclude={"report_digest"}) + return _content_digest(payload) diff --git a/ksadk/evaluation/evalset.py b/ksadk/evaluation/evalset.py new file mode 100644 index 00000000..82e52d34 --- /dev/null +++ b/ksadk/evaluation/evalset.py @@ -0,0 +1,351 @@ +"""EvalSet format detection and loss-aware file loading.""" + +from __future__ import annotations + +import json +from enum import Enum +from pathlib import Path +from typing import Any, Mapping + +import yaml + +from .contracts import AssertionSpec, EvalSetVersion + + +class EvalSetFormat(str, Enum): + NATIVE = "native" + STUDIO_SUITE = "studio_suite/v0" + ADK = "adk_evalset/v1" + + +class EvalSetParseError(ValueError): + """A user-facing, safe parse error with a stable code.""" + + def __init__(self, code: str, message: str, *, path: str | None = None): + self.code = code + self.path = path + super().__init__(message if path is None else f"{message}: {path}") + + +_LEGACY_ASSERTIONS = { + "contains": "response.contains", + "equals": "response.equals", + "notContains": "response.notContains", + "jsonSchema": "response.jsonSchema", + "maxLatencyMs": "runtime.maxLatencyMs", + "maxInputTokens": "runtime.maxInputTokens", + "maxOutputTokens": "runtime.maxOutputTokens", + "toolCalled": "tool.called", + "toolNotCalled": "tool.notCalled", +} + +_STUDIO_ROOT_FIELDS = {"apiVersion", "api_version", "kind", "metadata", "cases"} +_STUDIO_CASE_FIELDS = {"id", "input", "assertions"} +_ADK_ROOT_FIELDS = { + "eval_set_id", + "evalSetId", + "name", + "description", + "eval_cases", + "evalCases", + "creation_timestamp", + "creationTimestamp", +} +_ADK_CASE_FIELDS = { + "eval_id", + "evalId", + "id", + "conversation", + "session_input", + "sessionInput", + "creation_timestamp", + "creationTimestamp", + "assertions", +} +_ADK_TURN_FIELDS = { + "invocation_id", + "invocationId", + "user_content", + "userContent", + "final_response", + "finalResponse", + "intermediate_data", + "intermediateData", + "creation_timestamp", + "creationTimestamp", +} + + +def identify_evalset_format(value: Mapping[str, Any]) -> EvalSetFormat: + """Identify a supported EvalSet without attempting to execute it.""" + + if value.get("schemaVersion") == "ksadk.eval/v1" or value.get("schema_version") == ( + "ksadk.eval/v1" + ): + return EvalSetFormat.NATIVE + if value.get("kind") == "EvaluationSuite": + return EvalSetFormat.STUDIO_SUITE + if isinstance(value.get("eval_cases", value.get("evalCases")), list): + return EvalSetFormat.ADK + raise EvalSetParseError( + "UNSUPPORTED_EVALSET_FORMAT", + "无法识别评测集格式,期望 ksadk.eval/v1、EvaluationSuite 或 ADK eval_cases", + ) + + +def parse_evalset(value: Mapping[str, Any]) -> EvalSetVersion: + """Parse and normalize an in-memory EvalSet mapping.""" + + if not isinstance(value, Mapping): + raise EvalSetParseError("EVALSET_ROOT_INVALID", "评测集根节点必须是对象") + detected = identify_evalset_format(value) + try: + if detected is EvalSetFormat.NATIVE: + data = dict(value) + data.setdefault("sourceFormat", detected.value) + return EvalSetVersion.model_validate(data) + if detected is EvalSetFormat.STUDIO_SUITE: + return _parse_studio_suite(value) + return _parse_adk_evalset(value) + except EvalSetParseError: + raise + except (TypeError, ValueError) as exc: + raise EvalSetParseError("EVALSET_INVALID", "评测集内容无效") from exc + + +def load_evalset(path: str | Path) -> EvalSetVersion: + """Read YAML/JSON and return the normalized, digest-bearing EvalSet.""" + + file_path = Path(path) + if not file_path.is_file(): + raise EvalSetParseError( + "EVALSET_FILE_NOT_FOUND", "评测集文件不存在", path=str(path) + ) + try: + raw = file_path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise EvalSetParseError( + "EVALSET_ENCODING_INVALID", "评测集必须是 UTF-8 文件", path=str(path) + ) from exc + try: + value = json.loads(raw) if file_path.suffix.lower() == ".json" else yaml.safe_load(raw) + except (json.JSONDecodeError, yaml.YAMLError) as exc: + raise EvalSetParseError( + "EVALSET_FILE_INVALID", + "评测集不是有效的 JSON/YAML", + path=str(path), + ) from exc + try: + return parse_evalset(value) + except EvalSetParseError as exc: + if exc.path is not None: + raise + raise EvalSetParseError(exc.code, str(exc), path=str(path)) from exc + + +def _parse_studio_suite(value: Mapping[str, Any]) -> EvalSetVersion: + _reject_unknown_fields(value, _STUDIO_ROOT_FIELDS, "Studio EvaluationSuite") + metadata = value.get("metadata") + if not isinstance(metadata, Mapping): + raise EvalSetParseError( + "EVALSET_METADATA_INVALID", "Studio EvaluationSuite 缺少 metadata" + ) + cases = value.get("cases") + if not isinstance(cases, list): + raise EvalSetParseError( + "EVALSET_CASES_INVALID", "Studio EvaluationSuite 的 cases 必须是数组" + ) + normalized = [] + for index, case in enumerate(cases): + if not isinstance(case, Mapping): + raise EvalSetParseError( + "EVALSET_CASE_INVALID", f"第 {index + 1} 个 case 必须是对象" + ) + _reject_unknown_fields(case, _STUDIO_CASE_FIELDS, f"Studio case {index + 1}") + assertions = case.get("assertions", []) + if not isinstance(assertions, list): + raise EvalSetParseError( + "EVALSET_ASSERTIONS_INVALID", + f"case {case.get('id', index)} 的 assertions 必须是数组", + ) + normalized.append( + { + "id": case.get("id", f"case-{index + 1}"), + "input": case.get("input"), + "assertions": [_normalize_assertion(item) for item in assertions], + } + ) + return EvalSetVersion( + name=str(metadata.get("name") or value.get("name") or "studio-suite"), + cases=normalized, + metadata=dict(metadata), + source_format=EvalSetFormat.STUDIO_SUITE.value, + ) + + +def _parse_adk_evalset(value: Mapping[str, Any]) -> EvalSetVersion: + _reject_unknown_fields(value, _ADK_ROOT_FIELDS, "ADK EvalSet") + raw_cases = value.get("eval_cases", value.get("evalCases")) + if not isinstance(raw_cases, list): + raise EvalSetParseError("EVALSET_CASES_INVALID", "ADK eval_cases 必须是数组") + normalized = [] + for index, raw_case in enumerate(raw_cases): + if not isinstance(raw_case, Mapping): + raise EvalSetParseError( + "EVALSET_CASE_INVALID", f"第 {index + 1} 个 ADK case 必须是对象" + ) + _reject_unknown_fields(raw_case, _ADK_CASE_FIELDS, f"ADK case {index + 1}") + conversations = raw_case.get("conversation", []) + if not isinstance(conversations, list) or not conversations: + raise EvalSetParseError( + "EVALSET_CONVERSATION_INVALID", + f"ADK case {index + 1} 缺少 conversation", + ) + turns = [_adk_turn(turn, index) for turn in conversations] + assertions = raw_case.get("assertions", []) + if not isinstance(assertions, list): + raise EvalSetParseError( + "EVALSET_ASSERTIONS_INVALID", + f"ADK case {index + 1} 的 assertions 必须是数组", + ) + normalized.append( + { + "id": str( + raw_case.get("eval_id") + or raw_case.get("evalId") + or raw_case.get("id") + or f"case-{index + 1}" + ), + "turns": turns, + "assertions": [_normalize_assertion(item) for item in assertions], + "metadata": { + "sessionInput": raw_case.get( + "session_input", raw_case.get("sessionInput") + ), + "creationTimestamp": raw_case.get( + "creation_timestamp", raw_case.get("creationTimestamp") + ), + }, + } + ) + return EvalSetVersion( + name=str( + value.get("name") + or value.get("eval_set_id") + or value.get("evalSetId") + or "adk-evalset" + ), + cases=normalized, + metadata={ + "description": value.get("description"), + "creationTimestamp": value.get( + "creation_timestamp", value.get("creationTimestamp") + ), + }, + source_format=EvalSetFormat.ADK.value, + ) + + +def _adk_turn(value: Any, case_index: int) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise EvalSetParseError( + "EVALSET_TURN_INVALID", + f"ADK case {case_index + 1} 的 turn 必须是对象", + ) + _reject_unknown_fields(value, _ADK_TURN_FIELDS, f"ADK case {case_index + 1} turn") + user_content = value.get("user_content", value.get("userContent")) + final_response = value.get("final_response", value.get("finalResponse")) + input_text = _part_text(user_content) + output_text = _part_text(final_response) + if not input_text: + input_text = str(value.get("input") or "") + if not input_text: + raise EvalSetParseError( + "EVALSET_INPUT_INVALID", + f"ADK case {case_index + 1} 的 turn 缺少用户输入", + ) + intermediate = value.get("intermediate_data", value.get("intermediateData")) or {} + if not isinstance(intermediate, Mapping): + raise EvalSetParseError( + "EVALSET_INTERMEDIATE_INVALID", + f"ADK case {case_index + 1} 的 intermediate_data 必须是对象", + ) + _reject_unknown_fields( + intermediate, + {"tool_uses", "toolUses", "intermediate_responses", "intermediateResponses"}, + f"ADK case {case_index + 1} intermediate_data", + ) + intermediate_responses = intermediate.get( + "intermediate_responses", intermediate.get("intermediateResponses", []) + ) + if intermediate_responses: + raise EvalSetParseError( + "EVALSET_UNSUPPORTED_INTERMEDIATE_RESPONSE", + f"ADK case {case_index + 1} 含暂不支持的 intermediate_responses", + ) + tools = intermediate.get("tool_uses", intermediate.get("toolUses", [])) + if not isinstance(tools, list): + raise EvalSetParseError( + "EVALSET_TOOLS_INVALID", + f"ADK case {case_index + 1} 的工具轨迹必须是数组", + ) + return { + "input": input_text, + "expected_output": output_text or None, + "expected_tools": tools, + "metadata": { + "invocationId": value.get("invocation_id", value.get("invocationId")), + "creationTimestamp": value.get( + "creation_timestamp", value.get("creationTimestamp") + ), + }, + } + + +def _part_text(value: Any) -> str: + if isinstance(value, str): + return value.strip() + if not isinstance(value, Mapping): + return "" + parts = value.get("parts", []) + if not isinstance(parts, list): + return "" + texts = [] + for part in parts: + if not isinstance(part, Mapping) or set(part) - {"text"}: + raise EvalSetParseError( + "EVALSET_UNSUPPORTED_CONTENT_PART", + "ADK EvalSet 暂只支持 text Part", + ) + if part.get("text"): + texts.append(str(part["text"])) + return "\n".join(texts) + + +def _normalize_assertion(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise EvalSetParseError("EVALSET_ASSERTION_INVALID", "断言必须是对象") + data = dict(value) + assertion_type = data.pop("type", None) + if assertion_type in _LEGACY_ASSERTIONS: + assertion_type = _LEGACY_ASSERTIONS[assertion_type] + if not isinstance(assertion_type, str): + raise EvalSetParseError("EVALSET_ASSERTION_TYPE_INVALID", "断言缺少 type") + data["type"] = assertion_type + try: + return AssertionSpec.model_validate(data).model_dump(mode="python", by_alias=False) + except ValueError as exc: + raise EvalSetParseError( + "EVALSET_ASSERTION_INVALID", f"不支持或参数无效的断言: {assertion_type}" + ) from exc + + +def _reject_unknown_fields( + value: Mapping[str, Any], allowed: set[str], location: str +) -> None: + unknown = sorted(set(value) - allowed) + if unknown: + raise EvalSetParseError( + "EVALSET_UNSUPPORTED_FIELD", + f"{location} 含暂不支持的字段: {', '.join(unknown)}", + ) diff --git a/ksadk/evaluation/evaluators.py b/ksadk/evaluation/evaluators.py new file mode 100644 index 00000000..258cb8f6 --- /dev/null +++ b/ksadk/evaluation/evaluators.py @@ -0,0 +1,461 @@ +"""Deterministic evaluators for normalized target results.""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +from collections import Counter +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from jsonschema import ValidationError as JSONSchemaValidationError +from jsonschema import validate as validate_json + +from .contracts import ( + AssertionSpec, + AssertionType, + DataPolicy, + EvalCase, + EvaluationConfig, + MetricResult, + MetricStatus, + TargetRun, +) + + +@dataclass(frozen=True) +class _EvaluationContext: + """Inputs shared by every evaluator for one target invocation.""" + + case: EvalCase + target_run: TargetRun + config: EvaluationConfig + + +EvaluatorStrategy = Callable[[_EvaluationContext], list[MetricResult]] + + +@dataclass(frozen=True) +class _EvaluatorDefinition: + """Stable evaluator metadata and its stateless scoring strategy.""" + + evaluator_id: str + metric_name: str + strategy: EvaluatorStrategy + + +REFERENCE_MATCH_EVALUATOR = "reference_match@v1" +LLM_JUDGE_EVALUATOR = "llm_judge@v1" +DEFAULT_EVALUATORS = ( + "response_contract@v1", + "runtime_budget@v1", + "tool_trajectory@v1", +) +_RESPONSE_MATCH_THRESHOLD = 0.8 +_TOKEN_PATTERN = re.compile(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]") + + +def evaluate_case( + case: EvalCase, + target_run: TargetRun, + evaluator_names: list[str], + config: EvaluationConfig | None = None, +) -> list[MetricResult]: + """Run selected evaluators in request order.""" + + context = _EvaluationContext(case, target_run, config or EvaluationConfig()) + evaluators = _resolve_evaluators(evaluator_names) + return _run_evaluators(context, evaluators) + + +async def evaluate_case_async( + case: EvalCase, + target_run: TargetRun, + evaluator_names: list[str], + config: EvaluationConfig, +) -> list[MetricResult]: + """Run evaluators without blocking the target invocation event loop.""" + + return await asyncio.to_thread(evaluate_case, case, target_run, evaluator_names, config) + + +def _resolve_evaluators(evaluator_names: list[str]) -> list[_EvaluatorDefinition]: + selected_names = evaluator_names or DEFAULT_EVALUATORS + unsupported_names = [name for name in selected_names if name not in _EVALUATOR_REGISTRY] + if unsupported_names: + raise ValueError(f"不支持的评估器: {', '.join(unsupported_names)}") + return [_EVALUATOR_REGISTRY[name] for name in selected_names] + + +def _run_evaluators( + context: _EvaluationContext, + evaluators: list[_EvaluatorDefinition], +) -> list[MetricResult]: + metrics: list[MetricResult] = [] + for evaluator in evaluators: + metrics.extend(_run_evaluator(evaluator, context)) + return metrics + + +def _run_evaluator( + evaluator: _EvaluatorDefinition, + context: _EvaluationContext, +) -> list[MetricResult]: + try: + return evaluator.strategy(context) + except Exception as exc: + return [_evaluator_error_metric(evaluator, exc)] + + +def _evaluate_reference_match(context: _EvaluationContext) -> list[MetricResult]: + """Score the final response against the case's reference output.""" + + expected_output = _final_expected_output(context.case) + if not expected_output: + return [] + + score = _rouge_1_f1(context.target_run.output, expected_output) + return [ + _score_metric( + name="response_match", + score=score, + evidence={ + "evaluator": REFERENCE_MATCH_EVALUATOR, + "method": "rouge_1_f1", + "threshold": _RESPONSE_MATCH_THRESHOLD, + }, + ) + ] + + +def _evaluate_llm_judge( + context: _EvaluationContext, +) -> list[MetricResult]: + """Use the configured LLM Judge to score a final response.""" + + expected_output = _final_expected_output(context.case) + if not expected_output: + return [] + + unavailable_reason = _judge_unavailable_reason(context.config) + if unavailable_reason: + return [_judge_unavailable_metric(unavailable_reason)] + + try: + score = _judge_score( + input_text=context.case.input, + actual_output=context.target_run.output, + expected_output=expected_output, + config=context.config, + ) + except ImportError: + return [_judge_unavailable_metric("未安装 ksadk[judge]")] + except Exception as exc: + return [_judge_error_metric(exc)] + + return [_judge_score_metric(score, context.config.judge_model)] + + +def _evaluate_response_contract( + context: _EvaluationContext, +) -> list[MetricResult]: + """Evaluate response assertions against the normalized target output.""" + + return [ + _response_metric(assertion, context.target_run.output) + for assertion in _response_assertions(context.case) + ] + + +def _evaluate_runtime_budget( + context: _EvaluationContext, +) -> list[MetricResult]: + """Evaluate runtime assertions against adapter-reported measurements.""" + + return [ + _runtime_metric(assertion, context.target_run) + for assertion in _runtime_assertions(context.case) + ] + + +def _evaluate_tool_trajectory( + context: _EvaluationContext, +) -> list[MetricResult]: + """Report unavailable until A2A exposes normalized tool trajectories.""" + + requirements = _tool_requirements(context.case) + if not requirements: + return [] + + return [ + MetricResult( + name="tool_trajectory", + status=MetricStatus.UNAVAILABLE, + required=required, + evidence={ + "assertion": assertion_type, + "reason": "A2A target 未提供标准化工具轨迹", + }, + ) + for assertion_type, required in requirements + ] + + +def _response_assertions(case: EvalCase) -> list[AssertionSpec]: + response_types = { + AssertionType.RESPONSE_EQUALS, + AssertionType.RESPONSE_CONTAINS, + AssertionType.RESPONSE_NOT_CONTAINS, + AssertionType.RESPONSE_JSON_SCHEMA, + } + return [assertion for assertion in case.assertions if assertion.type in response_types] + + +def _runtime_assertions(case: EvalCase) -> list[AssertionSpec]: + return [ + assertion for assertion in case.assertions if assertion.type.value.startswith("runtime.") + ] + + +def _tool_requirements(case: EvalCase) -> list[tuple[str, bool]]: + requirements = [ + (assertion.type.value, assertion.required) + for assertion in case.assertions + if assertion.type in {AssertionType.TOOL_CALLED, AssertionType.TOOL_NOT_CALLED} + ] + requirements.extend(("tool.expected", True) for turn in case.turns for _ in turn.expected_tools) + return requirements + + +def _response_metric(assertion: AssertionSpec, output: str) -> MetricResult: + reason = "" + if assertion.type is AssertionType.RESPONSE_EQUALS: + passed = output == assertion.value + elif assertion.type is AssertionType.RESPONSE_CONTAINS: + passed = assertion.value in output + elif assertion.type is AssertionType.RESPONSE_NOT_CONTAINS: + passed = assertion.value not in output + else: + try: + validate_json(json.loads(output), assertion.value) + passed = True + except (json.JSONDecodeError, JSONSchemaValidationError) as exc: + passed = False + reason = str(exc) + + return _metric( + "response_contract", + assertion, + passed=passed, + reason=reason, + ) + + +def _runtime_metric(assertion: AssertionSpec, target_run: TargetRun) -> MetricResult: + actual: int | None + if assertion.type is AssertionType.RUNTIME_MAX_LATENCY_MS: + actual = target_run.duration_ms + elif not target_run.usage.reported: + actual = None + elif assertion.type is AssertionType.RUNTIME_MAX_INPUT_TOKENS: + actual = target_run.usage.input_tokens + else: + actual = target_run.usage.output_tokens + + if actual is None: + return MetricResult( + name="runtime_budget", + status=MetricStatus.UNAVAILABLE, + required=assertion.required, + evidence={ + "assertion": assertion.type.value, + "reason": "Target 未提供该运行指标", + }, + ) + return _metric( + "runtime_budget", + assertion, + passed=actual <= assertion.value, + evidence={"actual": actual}, + ) + + +def _metric( + name: str, + assertion: AssertionSpec, + *, + passed: bool, + reason: str = "", + evidence: dict[str, Any] | None = None, +) -> MetricResult: + details = {"assertion": assertion.type.value, **(evidence or {})} + if reason: + details["reason"] = reason + return MetricResult( + name=name, + status=MetricStatus.PASS if passed else MetricStatus.FAIL, + score=1.0 if passed else 0.0, + required=assertion.required, + evidence=details, + ) + + +def _evaluator_error_metric( + evaluator: _EvaluatorDefinition, + error: Exception, +) -> MetricResult: + return MetricResult( + name=evaluator.metric_name, + status=MetricStatus.ERROR, + evidence={ + "evaluator": evaluator.evaluator_id, + "reason": "评估器执行失败", + "errorType": type(error).__name__, + }, + ) + + +def _score_metric( + *, + name: str, + score: float, + evidence: dict[str, Any], +) -> MetricResult: + return MetricResult( + name=name, + status=MetricStatus.PASS if score >= _RESPONSE_MATCH_THRESHOLD else MetricStatus.FAIL, + score=score, + evidence=evidence, + ) + + +def _final_expected_output(case: EvalCase) -> str | None: + return case.turns[-1].expected_output + + +def _judge_unavailable_reason(config: EvaluationConfig) -> str | None: + if config.data_policy is not DataPolicy.FULL_TRACE: + return "Judge 需要 dataPolicy=full_trace" + if not config.judge_model: + return "未配置 judgeModel" + if not config.judge_api_base: + return "未配置 judgeApiBase" + if not os.getenv(config.judge_api_key_env): + return f"未设置 Judge 密钥环境变量: {config.judge_api_key_env}" + return None + + +def _judge_score( + *, + input_text: str, + actual_output: str, + expected_output: str, + config: EvaluationConfig, +) -> float: + return _run_llm_judge( + input_text=input_text, + actual_output=actual_output, + expected_output=expected_output, + model=config.judge_model or "", + api_base=config.judge_api_base or "", + api_key=os.environ[config.judge_api_key_env], + ) + + +def _judge_unavailable_metric(reason: str) -> MetricResult: + return MetricResult( + name="response_quality", + status=MetricStatus.UNAVAILABLE, + evidence={"evaluator": LLM_JUDGE_EVALUATOR, "reason": reason}, + ) + + +def _judge_error_metric(error: Exception) -> MetricResult: + return MetricResult( + name="response_quality", + status=MetricStatus.ERROR, + evidence={ + "evaluator": LLM_JUDGE_EVALUATOR, + "reason": "Judge 执行失败", + "errorType": type(error).__name__, + }, + ) + + +def _judge_score_metric(score: float, judge_model: str | None) -> MetricResult: + return _score_metric( + name="response_quality", + score=score, + evidence={ + "evaluator": LLM_JUDGE_EVALUATOR, + "threshold": _RESPONSE_MATCH_THRESHOLD, + "judgeModel": judge_model, + }, + ) + + +def _rouge_1_f1(actual: str, expected: str) -> float: + actual_tokens = Counter(_TOKEN_PATTERN.findall(actual.lower())) + expected_tokens = Counter(_TOKEN_PATTERN.findall(expected.lower())) + if not actual_tokens or not expected_tokens: + return float(actual.strip() == expected.strip()) + + overlap = sum((actual_tokens & expected_tokens).values()) + precision = overlap / sum(actual_tokens.values()) + recall = overlap / sum(expected_tokens.values()) + return 2 * precision * recall / (precision + recall) if precision + recall else 0.0 + + +def _run_llm_judge( + *, + input_text: str, + actual_output: str, + expected_output: str, + model: str, + api_base: str, + api_key: str, +) -> float: + """Run the minimal external Judge configuration.""" + + os.environ.setdefault("DEEPEVAL_TELEMETRY_OPT_OUT", "YES") + from deepeval.metrics import GEval + from deepeval.models import LocalModel + from deepeval.test_case import LLMTestCase, LLMTestCaseParams + + judge = LocalModel(model=model, base_url=api_base, api_key=api_key) + metric = GEval( + name="Response quality", + criteria=( + "Determine whether the actual output is factually correct " + "based on the expected output." + ), + evaluation_params=[ + LLMTestCaseParams.INPUT, + LLMTestCaseParams.ACTUAL_OUTPUT, + LLMTestCaseParams.EXPECTED_OUTPUT, + ], + threshold=_RESPONSE_MATCH_THRESHOLD, + model=judge, + ) + metric.measure( + LLMTestCase( + input=input_text, + actual_output=actual_output, + expected_output=expected_output, + ) + ) + return float(metric.score) + + +_EVALUATORS = ( + _EvaluatorDefinition("response_contract@v1", "response_contract", _evaluate_response_contract), + _EvaluatorDefinition("runtime_budget@v1", "runtime_budget", _evaluate_runtime_budget), + _EvaluatorDefinition("tool_trajectory@v1", "tool_trajectory", _evaluate_tool_trajectory), + _EvaluatorDefinition(REFERENCE_MATCH_EVALUATOR, "response_match", _evaluate_reference_match), + _EvaluatorDefinition(LLM_JUDGE_EVALUATOR, "response_quality", _evaluate_llm_judge), +) +_EVALUATOR_REGISTRY = {evaluator.evaluator_id: evaluator for evaluator in _EVALUATORS} +SUPPORTED_EVALUATORS = tuple(evaluator.evaluator_id for evaluator in _EVALUATORS) diff --git a/ksadk/evaluation/executor.py b/ksadk/evaluation/executor.py new file mode 100644 index 00000000..6b523f8a --- /dev/null +++ b/ksadk/evaluation/executor.py @@ -0,0 +1,118 @@ +"""Single public evaluation handoff shared by CLI and Studio.""" + +from __future__ import annotations + +from collections.abc import Callable +from uuid import uuid4 + +from .adapters import EvaluationNotImplementedError, TargetAdapter +from .contracts import ( + CaseRun, + EvalRunReport, + EvalRunSpec, + EvalRunStatus, + EvaluationRequest, + MetricStatus, + TargetRunStatus, +) +from .evaluators import evaluate_case_async +from .storage import EvaluationStorage, EvaluationStorageError +from .target import EvaluationExecutionError, EvaluationTarget + +__all__ = [ + "EvaluationExecutionError", + "EvaluationNotImplementedError", + "TargetAdapter", + "execute_evaluation", +] + + +async def execute_evaluation( + request: EvaluationRequest, + *, + on_case_started: Callable[[str, int, int], None] | None = None, +) -> EvalRunReport: + """Execute and persist one evaluation request.""" + + target = EvaluationTarget(request.target, request.config) + snapshot = await target.snapshot() + spec = EvalRunSpec( + id=f"eval_{uuid4().hex}", + evalset=request.evalset, + target=snapshot, + config=request.config, + ) + case_runs = await _run_cases(target, spec, on_case_started=on_case_started) + report = EvalRunReport( + spec=spec, + status=_report_status(case_runs), + case_runs=case_runs, + ) + _persist_report(request, report) + return report + + +async def _run_cases( + target: EvaluationTarget, + spec: EvalRunSpec, + *, + on_case_started: Callable[[str, int, int], None] | None, +) -> list[CaseRun]: + case_runs: list[CaseRun] = [] + total_cases = len(spec.evalset.cases) + for index, case in enumerate(spec.evalset.cases, start=1): + _notify_case_started(on_case_started, case.id, index, total_cases) + target_run = await target.run_case(spec, case) + try: + metrics = await evaluate_case_async( + case, target_run, spec.config.evaluators, spec.config + ) + except ValueError as exc: + raise EvaluationExecutionError(str(exc)) from exc + case_run = CaseRun( + case_id=case.id, + attempt=spec.attempt, + target_run=target_run, + metrics=metrics, + ) + case_runs.append(case_run) + if spec.config.fail_fast and not case_run.passed: + break + return case_runs + + +def _notify_case_started( + callback: Callable[[str, int, int], None] | None, + case_id: str, + index: int, + total_cases: int, +) -> None: + if callback is None: + return + try: + callback(case_id, index, total_cases) + except Exception: + pass + + +def _report_status(case_runs: list[CaseRun]) -> EvalRunStatus: + if any( + case.target_run.status is TargetRunStatus.ERROR + or any(metric.required and metric.status is MetricStatus.ERROR for metric in case.metrics) + for case in case_runs + ): + return EvalRunStatus.ERROR + if any(case.target_run.status is TargetRunStatus.CANCELLED for case in case_runs): + return EvalRunStatus.CANCELLED + if any(not case.passed for case in case_runs): + return EvalRunStatus.FAILED + return EvalRunStatus.PASSED + + +def _persist_report(request: EvaluationRequest, report: EvalRunReport) -> None: + if not request.report_dir: + return + try: + EvaluationStorage(request.report_dir).write_report(report) + except EvaluationStorageError as exc: + raise EvaluationExecutionError("评测报告写入失败") from exc diff --git a/ksadk/evaluation/storage.py b/ksadk/evaluation/storage.py new file mode 100644 index 00000000..74b2bfec --- /dev/null +++ b/ksadk/evaluation/storage.py @@ -0,0 +1,68 @@ +"""Local, atomic storage for the shared evaluation report contract.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .contracts import EvalRunReport + + +class EvaluationStorageError(RuntimeError): + """Raised when a report cannot be read or safely persisted.""" + + +class EvaluationStorage: + """Persist reports below one workspace-owned evaluation directory.""" + + def __init__(self, root: str | Path): + self.root = Path(root).expanduser().resolve() + + def report_path(self, run_id: str) -> Path: + """Return the only report location allowed for a run identifier.""" + + if not run_id or Path(run_id).name != run_id: + raise EvaluationStorageError("run_id 只能包含文件名,不允许路径穿越") + return self.root / run_id / "report.json" + + def write_report(self, report: EvalRunReport) -> Path: + """Write the completed report atomically after its artifact directory exists.""" + + path = self.report_path(report.spec.id) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{report.report_digest}.tmp") + payload = json.dumps( + report.model_dump(mode="json", by_alias=True, exclude_none=True), + ensure_ascii=False, + sort_keys=True, + indent=2, + ) + "\n" + try: + temporary.write_text(payload, encoding="utf-8") + temporary.replace(path) + except OSError as exc: + temporary.unlink(missing_ok=True) + raise EvaluationStorageError("评测报告写入失败") from exc + return path + + def read_report(self, run_id: str) -> EvalRunReport: + path = self.report_path(run_id) + try: + return EvalRunReport.model_validate_json(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise EvaluationStorageError("评测报告不存在") from exc + except (OSError, ValueError) as exc: + raise EvaluationStorageError("评测报告损坏或不可读") from exc + + def list_reports(self) -> list[EvalRunReport]: + if not self.root.is_dir(): + return [] + reports: list[EvalRunReport] = [] + for path in sorted(self.root.glob("*/report.json")): + try: + reports.append( + EvalRunReport.model_validate_json(path.read_text(encoding="utf-8")) + ) + except (OSError, ValueError): + continue + return reports diff --git a/ksadk/evaluation/target.py b/ksadk/evaluation/target.py new file mode 100644 index 00000000..d200c6a9 --- /dev/null +++ b/ksadk/evaluation/target.py @@ -0,0 +1,73 @@ +"""Common target lifecycle shared by all evaluation adapters.""" + +from __future__ import annotations + +import asyncio + +from .adapters import ( + EvaluationNotImplementedError, + TargetAdapter, + TargetAdapterError, + create_target_adapter, +) +from .contracts import ( + EvalCase, + EvalRunSpec, + EvaluationConfig, + TargetRef, + TargetRun, + TargetRunStatus, + TargetSnapshot, +) + + +class EvaluationExecutionError(RuntimeError): + """A classified failure from the common target execution boundary.""" + + +class EvaluationTarget: + """Own target lifecycle; protocol details stay in the selected adapter.""" + + def __init__(self, target: TargetRef, config: EvaluationConfig) -> None: + self.reference = target + self._timeout_seconds = config.timeout_seconds + self._adapter: TargetAdapter = create_target_adapter( + target, timeout_seconds=config.timeout_seconds + ) + + async def snapshot(self) -> TargetSnapshot: + try: + return await asyncio.wait_for( + self._adapter.snapshot(self.reference), + timeout=self._timeout_seconds, + ) + except TimeoutError as exc: + raise EvaluationExecutionError( + f"{self.reference.kind.value} Target 快照超时" + ) from exc + except TargetAdapterError as exc: + raise EvaluationExecutionError(f"{exc.code}: {exc}") from exc + + async def run_case( + self, + spec: EvalRunSpec, + case: EvalCase, + ) -> TargetRun: + try: + return await asyncio.wait_for( + self._adapter.run_case(spec, case, attempt=spec.attempt), + timeout=self._timeout_seconds, + ) + except TimeoutError: + return TargetRun( + status=TargetRunStatus.ERROR, + error_code="EVALUATION_CASE_TIMEOUT", + error_message=f"Case {case.id} 执行超时", + ) + + +__all__ = [ + "EvaluationExecutionError", + "EvaluationNotImplementedError", + "EvaluationTarget", +] diff --git a/ksadk/events/runtime_event.py b/ksadk/events/runtime_event.py index a1894798..c285ee48 100644 --- a/ksadk/events/runtime_event.py +++ b/ksadk/events/runtime_event.py @@ -64,6 +64,9 @@ class EventType: RUN_COMPLETED = "run.completed" RUN_FAILED = "run.failed" RUN_CANCELED = "run.canceled" + # context preprocessing. payload: phase, trigger; completed also carries cursor + CONTEXT_COMPACTION_STARTED = "context.compaction.started" + CONTEXT_COMPACTION_COMPLETED = "context.compaction.completed" # checkpoint。payload: checkpoint_id, granularity(delta|snapshot), resume_target? CHECKPOINT_CREATED = "checkpoint.created" CHECKPOINT_RESUMED = "checkpoint.resumed" @@ -100,6 +103,8 @@ class EventType: EventType.RUN_COMPLETED, EventType.RUN_FAILED, EventType.RUN_CANCELED, + EventType.CONTEXT_COMPACTION_STARTED, + EventType.CONTEXT_COMPACTION_COMPLETED, EventType.CHECKPOINT_CREATED, EventType.CHECKPOINT_RESUMED, EventType.USAGE_REPORTED, @@ -133,6 +138,10 @@ class EventType: EventType.RUN_COMPLETED: frozenset({"status"}), EventType.RUN_FAILED: frozenset({"status", "error"}), EventType.RUN_CANCELED: frozenset({"status"}), + EventType.CONTEXT_COMPACTION_STARTED: frozenset({"phase", "trigger"}), + EventType.CONTEXT_COMPACTION_COMPLETED: frozenset( + {"phase", "trigger", "compacted_until_seq_id"} + ), EventType.CHECKPOINT_CREATED: frozenset({"checkpoint_id", "granularity"}), EventType.CHECKPOINT_RESUMED: frozenset({"checkpoint_id"}), EventType.USAGE_REPORTED: frozenset({"input_tokens", "output_tokens", "total_tokens"}), diff --git a/ksadk/harness/__init__.py b/ksadk/harness/__init__.py index 81c40659..1c6ae028 100644 --- a/ksadk/harness/__init__.py +++ b/ksadk/harness/__init__.py @@ -13,7 +13,7 @@ HarnessToolCall, LiteLLMHarnessReasoner, ) -from ksadk.harness.runner import YamlAgentRunner +from ksadk.harness.runtime import HarnessRuntime, HarnessRuntimeAdapter from ksadk.harness.sandbox import HarnessSandboxExecutor, SandboxPolicyDenied __all__ = [ @@ -23,6 +23,8 @@ "HarnessConfigError", "HarnessPlugin", "HarnessReasoner", + "HarnessRuntime", + "HarnessRuntimeAdapter", "HarnessReasoningTurn", "HarnessSandboxExecutor", "HarnessToolCall", @@ -30,5 +32,4 @@ "McpToolSpec", "SandboxPolicy", "SandboxPolicyDenied", - "YamlAgentRunner", ] diff --git a/ksadk/harness/app.py b/ksadk/harness/app.py index 81e044f1..e1bb492d 100644 --- a/ksadk/harness/app.py +++ b/ksadk/harness/app.py @@ -1,20 +1,7 @@ -"""HarnessApp — 统一 Runtime 的可部署交付物 / composition root。 - -H2 §4.2:HarnessApp 是 composition root,**不包装复制 3000+ 行全局 app**——复用 -G0.1 ``create_runtime_app`` 装配;A2A、Responses、session、files 等**数据面** route -group 装配,**控制面 action 不进入**;不 import 全局 ``base_app`` 再过滤复制路由。 - -本层保持 AgentDraft 的最小字段面,但执行路径会运行真实模型推理、MCP transport 与 -Harness read-only sandbox policy。不含完整 AgentDraft v1 字段面、plugin 生态或 deploy 自动化。 - -分层职责:yaml → RuntimeAdapter ``start(request)`` 的映射在**本层**;平台请求 → -codex 配置(config.toml/AGENTS.md/mcp_servers)的翻译是 goal-09 CodexRuntime 的职责, -不在本层做。 -""" +"""RuntimeAdapter-first deployable Harness composition root.""" from __future__ import annotations -import logging import tempfile from dataclasses import dataclass, replace from pathlib import Path @@ -24,34 +11,32 @@ from ksadk.harness.config import HarnessConfig from ksadk.harness.reasoner import HarnessReasoner -from ksadk.harness.runner import YamlAgentRunner -from ksadk.runners.base_runner import BaseRunner -from ksadk.runtime.adapter import RuntimeAdapter, StartRequest -from ksadk.runtime.runner_adapter import RunnerRuntimeAdapter +from ksadk.harness.runtime import HarnessRuntimeAdapter +from ksadk.runtime import ( + RuntimeAdapter, + RuntimeExecutor, + RuntimeLaunchContext, + RuntimeRegistry, + StartRequest, +) +from ksadk.runtime.factory import create_runtime_adapter from ksadk.server.factory import RuntimeAppConfig, create_runtime_app from ksadk.server.routes.routers import GROUP_ROUTERS, load_route_modules -logger = logging.getLogger(__name__) - class HarnessPlugin(Protocol): - """基础 plugin host 的插件协议(骨架期最小钩子)。""" - - def before_build(self, config: HarnessConfig) -> None: # pragma: no cover - 协议 + def before_build(self, config: HarnessConfig) -> None: # pragma: no cover ... def after_build(self, app: FastAPI, config: HarnessConfig) -> None: # pragma: no cover ... -#: per-invocation override 允许覆盖的字段(最小子集内)。 _OVERRIDEABLE = ("model", "prompt") @dataclass(frozen=True) class HarnessCapabilities: - """Protocol/data surfaces backed by this Harness composition.""" - responses: bool = True sessions: bool = True files: bool = False @@ -60,14 +45,16 @@ class HarnessCapabilities: class HarnessApp: - """统一 Runtime 的 composition root(skeleton)。""" + """Own one adapter/executor/context tuple for a deployable YAML agent.""" def __init__( self, config: HarnessConfig, *, plugins: Sequence[HarnessPlugin] = (), - adapter_builder: Optional[Callable[[BaseRunner], RuntimeAdapter]] = None, + adapter_builder: Optional[ + Callable[[HarnessConfig, Path, HarnessReasoner | None], RuntimeAdapter] + ] = None, reasoner: HarnessReasoner | None = None, workspace_root: str | Path | None = None, a2a: Any | None = None, @@ -85,31 +72,59 @@ def __init__( self._owned_workspace = None resolved_workspace = workspace_root self._workspace_root = Path(resolved_workspace).resolve() - self._runner: BaseRunner | None = None self._adapter: RuntimeAdapter | None = None self._fastapi_app: FastAPI | None = None self._capabilities = HarnessCapabilities(a2a=a2a is not None) - # Each Harness owns its session backend. Route handlers may use this - # marker even when a host does not provide the optional session seam. + self._runtime_type = "codex" if config.runtime == "codex" else "harness" + self._launch_context = RuntimeLaunchContext( + runtime_type=self._runtime_type, + project_dir=self._workspace_root, + config={ + "model": config.model, + "base_instructions": config.prompt, + "sandbox_read_only": config.sandbox.read_only, + }, + ) + registry = RuntimeRegistry() + registry.register(self._runtime_type, lambda _context: self.adapter()) + self._executor = RuntimeExecutor(registry) + from ksadk.sessions import create_session_service self._session_service = create_session_service( - backend="memory", project_dir=str(self._workspace_root) + backend="memory", + project_dir=str(self._workspace_root), ) @classmethod def from_yaml(cls, path: str | Path, **kwargs: Any) -> "HarnessApp": - """从 yaml 装配(最小子集,超出明确报错)。""" return cls(HarnessConfig.from_yaml(path), **kwargs) @property def config(self) -> HarnessConfig: return self._config - # ---- override ---- + @property + def workspace_root(self) -> Path: + return self._workspace_root + + @property + def session_service(self) -> Any: + return self._session_service + + @property + def capabilities(self) -> HarnessCapabilities: + return self._capabilities + + @property + def executor(self) -> RuntimeExecutor: + return self._executor + + @property + def launch_context(self) -> RuntimeLaunchContext: + return self._launch_context def apply_overrides(self, overrides: Optional[dict[str, Any]]) -> HarnessConfig: - """per-invocation override:只允许最小子集内字段,超出报错(不静默忽略)。""" if not overrides: return self._config unknown = set(overrides) - set(_OVERRIDEABLE) @@ -119,60 +134,31 @@ def apply_overrides(self, overrides: Optional[dict[str, Any]]) -> HarnessConfig: ) return replace(self._config, **overrides) - # ---- 组装 ---- - - def build_runner(self, overrides: Optional[dict[str, Any]] = None) -> BaseRunner: - if overrides is not None: - raise ValueError("build_runner 不接受 invocation override;请传给 start(request)") - if self._runner is None: - self._runner = self._build_runner(self._config) - return self._runner - - def _build_runner(self, config: HarnessConfig) -> BaseRunner: - for plugin in self._plugins: - plugin.before_build(config) - if config.runtime == "codex": - # codex runtime:CodexRunner(经 ksadk web 已验证路径),detector stub 用 CODEX - from ksadk.detection.detector import FrameworkType - from ksadk.runners.codex_runner import CodexRunner - - detection = type( - "D", (), {"name": config.model, "type": FrameworkType.CODEX} - )() - return CodexRunner(detection, str(self._workspace_root)) - return YamlAgentRunner( - config, - reasoner=self._reasoner, - workspace_root=self._workspace_root, - ) - def adapter(self, overrides: Optional[dict[str, Any]] = None) -> RuntimeAdapter: - """yaml → RuntimeAdapter(goal-07);start(request) 在此层映射。""" if overrides is not None: raise ValueError("adapter 不接受 invocation override;请传给 start(request)") if self._adapter is not None: return self._adapter - rt = "codex" if self._config.runtime == "codex" else "harness" - - def _default_builder(runner: BaseRunner) -> RuntimeAdapter: - return RunnerRuntimeAdapter(runner, runtime_type=rt) - - builder = self._adapter_builder or _default_builder - self._adapter = builder(self.build_runner()) - return self._adapter - - @property - def runner(self) -> BaseRunner: - """The one runner owned by this Harness composition root.""" - return self.build_runner() - - @property - def session_service(self) -> Any: - return self._session_service - - @property - def capabilities(self) -> HarnessCapabilities: - return self._capabilities + for plugin in self._plugins: + plugin.before_build(self._config) + if self._adapter_builder is not None: + adapter = self._adapter_builder( + self._config, + self._workspace_root, + self._reasoner, + ) + elif self._runtime_type == "codex": + adapter = create_runtime_adapter(self._launch_context) + else: + adapter = HarnessRuntimeAdapter( + self._config, + reasoner=self._reasoner, + workspace_root=self._workspace_root, + ) + if not isinstance(adapter, RuntimeAdapter): + raise TypeError("Harness adapter builder must return RuntimeAdapter") + self._adapter = adapter + return adapter def _route_groups(self) -> set[str]: groups = {"health_meta", "models", "feedback", "tools", "ui_bootstrap"} @@ -187,49 +173,58 @@ def _route_groups(self) -> set[str]: return groups def _request_with_overrides( - self, request: StartRequest, overrides: Optional[dict[str, Any]] + self, + request: StartRequest, + overrides: Optional[dict[str, Any]], ) -> StartRequest: + effective = self.apply_overrides(overrides) metadata = dict(request.metadata or {}) - changed = False - if request.model: - metadata["model_override"] = request.model - changed = True - request_prompt = request.config.get("prompt") or request.config.get("instructions") - if request_prompt is not None: - metadata["prompt_override"] = str(request_prompt) - changed = True - if overrides: - config = self.apply_overrides(overrides) - metadata["model_override"] = config.model - metadata["prompt_override"] = config.prompt - changed = True - if not changed: - return request - if hasattr(request, "model_copy"): - return request.model_copy(update={"metadata": metadata}) - return request.copy(update={"metadata": metadata}) - - async def start(self, request: StartRequest, overrides: Optional[dict[str, Any]] = None): - """yaml → RuntimeAdapter.start(request)(本层映射)。""" - return await self.adapter().start(self._request_with_overrides(request, overrides)) + model = request.model or effective.model + prompt = str( + request.config.get("base_instructions") + or request.config.get("instructions") + or request.config.get("prompt") + or effective.prompt + ) + metadata["model_override"] = model + metadata["prompt_override"] = prompt + config = dict(request.config) + config["base_instructions"] = prompt + return request.model_copy( + update={ + "model": model, + "config": config, + "metadata": metadata, + } + ) + + async def start( + self, + request: StartRequest, + overrides: Optional[dict[str, Any]] = None, + ): + return await self._executor.start( + self._launch_context, + self._request_with_overrides(request, overrides), + ) def stream(self, handle: Any): - return self.adapter().stream(handle) + return self._executor.stream(handle) async def cancel(self, handle: Any): - return await self.adapter().cancel(handle) + return await self._executor.cancel(handle) async def resume(self, handle: Any, target: Any, payload: Any = None): - return await self.adapter().resume(handle, target, payload) + return await self._executor.resume(handle, target, payload) async def checkpoint(self, handle: Any): - return await self.adapter().checkpoint(handle) + return await self._executor.checkpoint(handle) async def close(self, handle: Any): - return await self.adapter().close(handle) + return await self._executor.close(handle) - def _configure_routes(self, app: FastAPI, state: Any, groups: set[str]) -> None: - """Attach only route groups; never install integrated workspace/terminal routes.""" + @staticmethod + def _configure_routes(app: FastAPI, state: Any, groups: set[str]) -> None: del state load_route_modules() ordered = sorted(group for group in groups if group != "health_meta") @@ -241,21 +236,19 @@ def _configure_routes(self, app: FastAPI, state: Any, groups: set[str]) -> None: app.include_router(router) def build_app(self, overrides: Optional[dict[str, Any]] = None) -> FastAPI: - """装配可部署 app:复用 create_runtime_app,只挂数据面 route group(控制面不进)。""" if overrides is not None: - # Overrides belong to StartRequest and must never create a second - # process-wide runner for the deployed app. raise ValueError("build_app 不接受 invocation override;请传给 start(request)") if self._fastapi_app is not None: return self._fastapi_app - runner = self.build_runner() + adapter = self.adapter() app = create_runtime_app( RuntimeAppConfig( - runner=runner, - runtime_type="codex" if self._config.runtime == "codex" else "harness", + runtime_type=self._runtime_type, route_groups=self._route_groups(), a2a=self._a2a, - runtime_adapter=self.adapter() if self._a2a is not None else None, + a2a_runtime_adapter=adapter if self._a2a is not None else None, + runtime_executor=self._executor, + launch_context=self._launch_context, session_backend_provider=lambda: { "Backend": "memory", "Shared": False, @@ -265,10 +258,8 @@ def build_app(self, overrides: Optional[dict[str, Any]] = None) -> FastAPI: ), self._configure_routes, ) - app.state.runtime.runtime_adapter = self.adapter() app.state.runtime.session_service = self._session_service app.state.runtime.harness_capabilities = self._capabilities - for plugin in self._plugins: plugin.after_build(app, self._config) self._fastapi_app = app diff --git a/ksadk/harness/config.py b/ksadk/harness/config.py index 5563a55d..6ffe1562 100644 --- a/ksadk/harness/config.py +++ b/ksadk/harness/config.py @@ -63,7 +63,7 @@ class HarnessConfig: mcp_tools: tuple[McpToolSpec, ...] = () sandbox: SandboxPolicy = field(default_factory=SandboxPolicy) runtime: str = "yaml" - """runtime 后端:``yaml``(YamlAgentRunner+LiteLLM)| ``codex``(CodexRunner+codex CLI)。""" + """runtime 后端:``yaml``(HarnessRuntimeAdapter)| ``codex``(CodexRuntimeAdapter)。""" @classmethod def from_dict(cls, data: dict[str, Any], *, source: str = "yaml") -> "HarnessConfig": diff --git a/ksadk/harness/runner.py b/ksadk/harness/runner.py deleted file mode 100644 index f83b0f5a..00000000 --- a/ksadk/harness/runner.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Runner that composes model reasoning with real MCP and sandbox tools.""" - -from __future__ import annotations - -import asyncio -import json -from pathlib import Path -from types import SimpleNamespace -from typing import Any, AsyncIterator, Dict - -from ksadk.harness.config import HarnessConfig -from ksadk.harness.reasoner import ( - HarnessReasoner, - HarnessReasoningTurn, - HarnessToolCall, - LiteLLMHarnessReasoner, -) -from ksadk.harness.sandbox import HarnessSandboxExecutor -from ksadk.harness.tools import HarnessTool, load_mcp_tools, sandbox_tools, tool_result_text -from ksadk.runners.base_runner import BaseRunner - -_MAX_REASONING_TURNS = 8 - - -class YamlAgentRunner(BaseRunner): - """Execute one Harness config through the shared runner/runtime contract.""" - - def __init__( - self, - config: HarnessConfig, - *, - agent_name: str = "harness-agent", - reasoner: HarnessReasoner | None = None, - workspace_root: str | Path = ".", - ) -> None: - super().__init__( - detection_result=SimpleNamespace( - name=agent_name, - type=SimpleNamespace(value="harness"), - ), - project_dir=str(workspace_root), - ) - self._config = config - self._reasoner = reasoner or LiteLLMHarnessReasoner() - self._sandbox = HarnessSandboxExecutor( - workspace_root=workspace_root, - read_only=config.sandbox.read_only, - ) - self._loaded = False - self._load_lock = asyncio.Lock() - self._tools: tuple[HarnessTool, ...] | None = None - self._mcp_toolsets: list[Any] = [] - - @property - def harness_config(self) -> HarnessConfig: - return self._config - - @property - def sandbox(self) -> HarnessSandboxExecutor: - return self._sandbox - - @property - def workspace_root(self) -> Path: - return self._sandbox.workspace_root - - def load_agent(self) -> None: - # MCP discovery is async and therefore happens lazily on first invocation. - self._loaded = True - - def prepare_for_request(self, model: str | None) -> None: - # Request model is carried in input_data. Do not mutate process-wide model env. - del model - - def _effective(self, input_data: Dict[str, Any]) -> tuple[str, str]: - metadata = input_data.get("metadata") or {} - if not isinstance(metadata, dict): - metadata = {} - model = str( - metadata.get("model_override") or input_data.get("model") or self._config.model - ).strip() - prompt = str( - metadata.get("prompt_override") or input_data.get("instructions") or self._config.prompt - ).strip() - return model, prompt - - async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: - tools = await self._ensure_tools() - model, prompt = self._effective(input_data) - user_input = str(input_data.get("input") or "") - messages: list[dict[str, Any]] = [ - {"role": "system", "content": prompt}, - {"role": "user", "content": user_input}, - ] - execution_log: list[dict[str, Any]] = [] - - for _turn_number in range(_MAX_REASONING_TURNS): - turn = await self._reasoner.complete( - model=model, - prompt=prompt, - messages=tuple(messages), - tools=tools, - ) - if turn.tool_calls: - messages.append( - { - "role": "assistant", - "content": turn.final_text, - "tool_calls": [ - { - "id": call.call_id, - "type": "function", - "function": { - "name": call.name, - "arguments": json.dumps(call.arguments, ensure_ascii=False), - }, - } - for call in turn.tool_calls - ], - } - ) - for call in turn.tool_calls: - tool = next((item for item in tools if item.name == call.name), None) - if tool is None: - raise RuntimeError( - f"Harness tool {call.name!r} is not available; it may be filtered " - "or was not exposed by its MCP server" - ) - result = await tool.call(call.arguments, call_id=call.call_id) - content = tool_result_text(result) - execution_log.append( - { - "call_id": call.call_id, - "name": call.name, - "source": tool.source, - "result": result, - } - ) - messages.append( - { - "role": "tool", - "tool_call_id": call.call_id, - "name": call.name, - "content": content, - } - ) - continue - if turn.final_text is None: - raise RuntimeError( - "Harness reasoner returned neither a final response nor a tool call" - ) - return { - "output": turn.final_text, - "model": model, - "prompt": prompt, - "tool_calls": execution_log, - "sandbox_read_only": self._config.sandbox.read_only, - } - raise RuntimeError(f"Harness reasoning exceeded {_MAX_REASONING_TURNS} turns") - - async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: - result = await self.invoke(input_data) - yield {"delta": result["output"], "type": "text"} - yield { - "output": result["output"], - "type": "final", - "model": result["model"], - "tool_calls": result["tool_calls"], - "sandbox_read_only": result["sandbox_read_only"], - } - - async def close(self) -> None: - toolsets, self._mcp_toolsets = self._mcp_toolsets, [] - self._tools = None - if toolsets: - await asyncio.gather(*(toolset.close() for toolset in toolsets), return_exceptions=True) - - async def _ensure_tools(self) -> tuple[HarnessTool, ...]: - if self._tools is not None: - return self._tools - async with self._load_lock: - if self._tools is not None: - return self._tools - tools = sandbox_tools(self._sandbox) - try: - for spec in self._config.mcp_tools: - toolset, discovered = await load_mcp_tools(spec) - self._mcp_toolsets.append(toolset) - tools.extend(discovered) - duplicates = sorted( - { - tool.name - for tool in tools - if sum(item.name == tool.name for item in tools) > 1 - } - ) - if duplicates: - raise RuntimeError(f"Harness tool names are not unique: {duplicates}") - except Exception: - await self.close() - raise - self._tools = tuple(tools) - return self._tools - - -__all__ = [ - "HarnessReasoner", - "HarnessReasoningTurn", - "HarnessToolCall", - "LiteLLMHarnessReasoner", - "YamlAgentRunner", -] diff --git a/ksadk/harness/runtime.py b/ksadk/harness/runtime.py new file mode 100644 index 00000000..582e373a --- /dev/null +++ b/ksadk/harness/runtime.py @@ -0,0 +1,364 @@ +"""Native RuntimeAdapter for YAML Harness agents.""" + +from __future__ import annotations + +import asyncio +import json +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ksadk.events import EventPhase, EventType, RuntimeEvent +from ksadk.harness.config import HarnessConfig +from ksadk.harness.reasoner import HarnessReasoner, LiteLLMHarnessReasoner +from ksadk.harness.sandbox import HarnessSandboxExecutor +from ksadk.harness.tools import HarnessTool, load_mcp_tools, sandbox_tools, tool_result_text +from ksadk.runtime import ( + BaseRuntime, + CancelResult, + CheckpointCapability, + CheckpointDescriptor, + ResumePayload, + ResumeTarget, + RunHandle, + RuntimeAdapter, + StartRequest, +) + +_MAX_REASONING_TURNS = 8 + + +class HarnessRuntime(BaseRuntime): + runtime_type = "harness" + + def native_capabilities(self) -> dict[str, Any]: + return { + "cancel": {"supported": True}, + "resume": {"supported": False}, + "checkpoint": {"supported": False, "granularity": "none"}, + "session_continuity": {"durable": False, "scope": "process"}, + } + + +@dataclass +class _HarnessRun: + request: StartRequest + task: asyncio.Task[dict[str, Any]] | None = None + pending_cancel: bool = False + done: bool = False + + +class HarnessRuntimeAdapter(RuntimeAdapter): + """Execute a YAML Harness config directly as RuntimeEvent streams.""" + + def __init__( + self, + config: HarnessConfig, + *, + agent_name: str = "harness-agent", + reasoner: HarnessReasoner | None = None, + workspace_root: str | Path = ".", + ) -> None: + super().__init__(HarnessRuntime()) + self._config = config + self._agent_name = agent_name + self._reasoner = reasoner or LiteLLMHarnessReasoner() + self._sandbox = HarnessSandboxExecutor( + workspace_root=workspace_root, + read_only=config.sandbox.read_only, + ) + self._runs: dict[str, _HarnessRun] = {} + self._tools: tuple[HarnessTool, ...] | None = None + self._tool_lock = asyncio.Lock() + self._mcp_toolsets: list[Any] = [] + + @property + def harness_config(self) -> HarnessConfig: + return self._config + + @property + def sandbox(self) -> HarnessSandboxExecutor: + return self._sandbox + + @property + def workspace_root(self) -> Path: + return self._sandbox.workspace_root + + async def start(self, request: StartRequest) -> RunHandle: + run_id = str(request.metadata.get("invocation_id") or f"harness_{uuid.uuid4().hex}") + if run_id in self._runs: + raise ValueError(f"duplicate Harness invocation: {run_id}") + self._runs[run_id] = _HarnessRun(request=request) + return RunHandle( + run_id=run_id, + session_id=request.session_id, + runtime_type="harness", + native_ref={"user_id": request.user_id, "agent_id": request.agent_id}, + ) + + def stream(self, handle: RunHandle): + return self._stream(handle) + + async def cancel(self, handle: RunHandle) -> CancelResult: + run = self._runs.get(handle.run_id) + if run is None or run.done: + return CancelResult.NOT_RUNNING + if run.task is None: + run.pending_cancel = True + return CancelResult.PENDING_CANCEL_RECORDED + if run.task.done(): + return CancelResult.NOT_RUNNING + run.task.cancel() + return CancelResult.INTERRUPTED_ACTIVE_TURN + + async def resume( + self, + handle: RunHandle, + target: ResumeTarget, + payload: ResumePayload | None, + ) -> RunHandle: + del handle, target, payload + raise RuntimeError("Harness runtime does not support resume") + + async def checkpoint(self, handle: RunHandle) -> CheckpointDescriptor: + return CheckpointDescriptor( + checkpoint_id=f"unsupported:{handle.run_id}", + invocation_id=handle.run_id, + capability=CheckpointCapability( + supported=False, + granularity="none", + rollback_scope="none", + fork_supported=False, + durable=False, + shared_across_pods=False, + reason="Harness runtime has no checkpoint backend", + ), + ) + + async def close(self, handle: RunHandle) -> None: + run = self._runs.pop(handle.run_id, None) + if run is not None and run.task is not None and not run.task.done(): + run.task.cancel() + await asyncio.gather(run.task, return_exceptions=True) + if not self._runs: + await self._close_tools() + + def is_handle_attached(self, handle: RunHandle) -> bool: + return handle.run_id in self._runs + + async def execute_request(self, request: StartRequest) -> dict[str, Any]: + tools = await self._ensure_tools() + model, prompt = self._effective(request) + messages: list[dict[str, Any]] = [ + {"role": "system", "content": prompt}, + {"role": "user", "content": str(request.input or "")}, + ] + execution_log: list[dict[str, Any]] = [] + + for _turn_number in range(_MAX_REASONING_TURNS): + turn = await self._reasoner.complete( + model=model, + prompt=prompt, + messages=tuple(messages), + tools=tools, + ) + if turn.tool_calls: + messages.append( + { + "role": "assistant", + "content": turn.final_text, + "tool_calls": [ + { + "id": call.call_id, + "type": "function", + "function": { + "name": call.name, + "arguments": json.dumps(call.arguments, ensure_ascii=False), + }, + } + for call in turn.tool_calls + ], + } + ) + for call in turn.tool_calls: + tool = next((item for item in tools if item.name == call.name), None) + if tool is None: + raise RuntimeError( + f"Harness tool {call.name!r} is not available; it may be filtered " + "or was not exposed by its MCP server" + ) + result = await tool.call(call.arguments, call_id=call.call_id) + content = tool_result_text(result) + execution_log.append( + { + "call_id": call.call_id, + "name": call.name, + "source": tool.source, + "arguments": dict(call.arguments), + "result": result, + } + ) + messages.append( + { + "role": "tool", + "tool_call_id": call.call_id, + "name": call.name, + "content": content, + } + ) + continue + if turn.final_text is None: + raise RuntimeError( + "Harness reasoner returned neither a final response nor a tool call" + ) + return { + "output": turn.final_text, + "model": model, + "prompt": prompt, + "tool_calls": execution_log, + "sandbox_read_only": self._config.sandbox.read_only, + } + raise RuntimeError(f"Harness reasoning exceeded {_MAX_REASONING_TURNS} turns") + + def _effective(self, request: StartRequest) -> tuple[str, str]: + metadata = request.metadata or {} + model = str( + metadata.get("model_override") or request.model or self._config.model + ).strip() + prompt = str( + metadata.get("prompt_override") + or request.config.get("base_instructions") + or request.config.get("instructions") + or request.config.get("prompt") + or self._config.prompt + ).strip() + return model, prompt + + async def _stream(self, handle: RunHandle): + run = self._require_run(handle) + seq = 0 + + def event( + event_type: str, + payload: dict[str, Any], + *, + phase: str | None = None, + ) -> RuntimeEvent: + nonlocal seq + seq += 1 + request = run.request + return RuntimeEvent.create( + event_type, + agent_id=str(request.agent_id or self._agent_name), + user_id=request.user_id, + session_id=request.session_id, + invocation_id=handle.run_id, + seq_id=seq, + payload=payload, + phase=phase, + ) + + if run.pending_cancel: + run.done = True + yield event( + EventType.RUN_CANCELED, + { + "status": "cancelled", + "cancel_result": CancelResult.PENDING_CANCEL_RECORDED.value, + }, + ) + return + yield event(EventType.RUN_STARTED, {"status": "in_progress"}) + run.task = asyncio.create_task(self.execute_request(run.request)) + try: + result = await run.task + for call in result["tool_calls"]: + yield event( + EventType.TOOL_CALL_BEGIN, + { + "call_id": call["call_id"], + "name": call["name"], + "args": call["arguments"], + }, + ) + yield event( + EventType.TOOL_CALL_END, + { + "call_id": call["call_id"], + "name": call["name"], + "result": call["result"], + }, + ) + text = str(result["output"]) + yield event( + EventType.TEXT_DELTA, + {"text": text}, + phase=EventPhase.FINAL_ANSWER.value, + ) + yield event( + EventType.TEXT_COMPLETED, + {"text": text}, + phase=EventPhase.FINAL_ANSWER.value, + ) + run.done = True + yield event(EventType.RUN_COMPLETED, {"status": "completed"}) + except asyncio.CancelledError: + run.done = True + yield event( + EventType.RUN_CANCELED, + { + "status": "cancelled", + "cancel_result": CancelResult.INTERRUPTED_ACTIVE_TURN.value, + }, + ) + except Exception as exc: # noqa: BLE001 + run.done = True + yield event( + EventType.RUN_FAILED, + {"status": "failed", "error": str(exc)}, + ) + + def _require_run(self, handle: RunHandle) -> _HarnessRun: + if handle.runtime_type != "harness": + raise ValueError(f"Harness adapter cannot stream {handle.runtime_type!r}") + try: + return self._runs[handle.run_id] + except KeyError: + raise KeyError(f"unknown Harness run: {handle.run_id}") from None + + async def _ensure_tools(self) -> tuple[HarnessTool, ...]: + if self._tools is not None: + return self._tools + async with self._tool_lock: + if self._tools is not None: + return self._tools + tools = sandbox_tools(self._sandbox) + try: + for spec in self._config.mcp_tools: + toolset, discovered = await load_mcp_tools(spec) + self._mcp_toolsets.append(toolset) + tools.extend(discovered) + duplicates = sorted( + { + tool.name + for tool in tools + if sum(item.name == tool.name for item in tools) > 1 + } + ) + if duplicates: + raise RuntimeError(f"Harness tool names are not unique: {duplicates}") + except Exception: + await self._close_tools() + raise + self._tools = tuple(tools) + return self._tools + + async def _close_tools(self) -> None: + toolsets, self._mcp_toolsets = self._mcp_toolsets, [] + self._tools = None + if toolsets: + await asyncio.gather(*(toolset.close() for toolset in toolsets), return_exceptions=True) + + +__all__ = ["HarnessRuntime", "HarnessRuntimeAdapter"] diff --git a/ksadk/hermes_terminal.py b/ksadk/hermes_terminal.py index 4e1b0360..7378b4e4 100644 --- a/ksadk/hermes_terminal.py +++ b/ksadk/hermes_terminal.py @@ -90,6 +90,7 @@ def build_start_frame( argv: Sequence[str], cols: int, rows: int, + session_id: str | None = None, cwd: str | None = None, options: Mapping[str, Any] | None = None, ) -> str: @@ -106,6 +107,9 @@ def build_start_frame( normalized_cwd = str(cwd or "").strip() if normalized_cwd: payload["cwd"] = normalized_cwd + normalized_session_id = str(session_id or "").strip() + if normalized_session_id: + payload["session_id"] = normalized_session_id if options: payload["options"] = dict(options) return json.dumps(payload, ensure_ascii=False) @@ -415,6 +419,7 @@ async def run_hermes_terminal_session( argv=normalized_argv, cols=size.cols, rows=size.rows, + session_id=session_id, cwd=cwd, options=options, ) diff --git a/ksadk/knowledge_base/client.py b/ksadk/knowledge_base/client.py index 48c30c3c..eb76a397 100644 --- a/ksadk/knowledge_base/client.py +++ b/ksadk/knowledge_base/client.py @@ -5,8 +5,9 @@ 环境变量: KSADK_KB_DATASET_ID: 知识库 ID (必填,存在即启用) - KSADK_KB_ACCESS_KEY: AK (可选,默认取 KSYUN_ACCESS_KEY) - KSADK_KB_SECRET_KEY: SK (可选,默认取 KSYUN_SECRET_KEY) + KSADK_KB_ACCESS_KEY: AK (可选,默认取 KSYUN_ACCESS_KEY / KSYUN_ACCESS_KEY_ID) + KSADK_KB_SECRET_KEY: SK (可选,默认取 KSYUN_SECRET_KEY / KSYUN_SECRET_ACCESS_KEY) + KSADK_KB_SESSION_TOKEN: STS 临时会话 token (可选,默认取 KSYUN_SESSION_TOKEN) KSADK_KB_REGION: 区域 (默认 cn-beijing-6) KSADK_KB_ENDPOINT: API 端点 (默认 aicp.api.ksyun.com) KSADK_KB_TOP_K: 返回结果数 (默认 5) @@ -59,6 +60,7 @@ class KnowledgeBaseClient(BaseModel): dataset_id: 知识库 ID (DatasetId) access_key: 访问密钥 ID (AK) secret_key: 访问密钥 (SK) + session_token: STS 临时会话 token region: API 区域 endpoint: API 端点 top_k: 返回结果数 @@ -71,6 +73,7 @@ class KnowledgeBaseClient(BaseModel): dataset_id: str access_key: str = "" secret_key: str = "" + session_token: str = "" region: str = "cn-beijing-6" endpoint: str = "aicp.api.ksyun.com" scheme: str = "https" @@ -133,7 +136,9 @@ def _get_client(self): "Ensure kingsoftcloud-sdk-python is installed and up to date." ) - cred = credential.Credential(self.access_key, self.secret_key) + cred = credential.Credential( + self.access_key, self.secret_key, self.session_token or None + ) http_profile = HttpProfile() http_profile.endpoint = self.endpoint @@ -255,12 +260,17 @@ def from_env(cls) -> "KnowledgeBaseClient": access_key = ( os.environ.get("KSADK_KB_ACCESS_KEY") or os.environ.get("KSYUN_ACCESS_KEY") + or os.environ.get("KSYUN_ACCESS_KEY_ID") or os.environ.get("KSYUN_SECRET_ID", "") ) secret_key = ( os.environ.get("KSADK_KB_SECRET_KEY") or os.environ.get("KSYUN_SECRET_KEY") - or os.environ.get("KSYUN_SECRET_KEY", "") + or os.environ.get("KSYUN_SECRET_ACCESS_KEY", "") + ) + session_token = ( + os.environ.get("KSADK_KB_SESSION_TOKEN") + or os.environ.get("KSYUN_SESSION_TOKEN", "") ) score_threshold_str = os.environ.get("KSADK_KB_SCORE_THRESHOLD", "") @@ -275,6 +285,7 @@ def from_env(cls) -> "KnowledgeBaseClient": dataset_id=dataset_id, access_key=access_key, secret_key=secret_key, + session_token=session_token, region=connection["region"], endpoint=connection["endpoint"], scheme=connection["scheme"], diff --git a/ksadk/managed_a2a_card.py b/ksadk/managed_a2a_card.py new file mode 100644 index 00000000..8545b0b2 --- /dev/null +++ b/ksadk/managed_a2a_card.py @@ -0,0 +1,160 @@ +"""Platform-layer discovery-only AgentCard mount for managed A2A Runtime. + +v1 scope (this module): + Mount ``GET /.well-known/agent-card.json`` only. No JSON-RPC, no identity + middleware, no TaskStore/ContextStore/ResumeStore. The card is available as + soon as the runtime starts, *before* any A2A Agent is registered, so the + server-side ``GetAToAAgentCard(hosted)`` probe can fetch it and break the + registration chicken-and-egg cycle. + + The card mounts whenever ``KSADK_A2A_RUNTIME_ID`` (ar-*, available at deploy + time) is non-empty. It does **not** depend on ``KSADK_A2A_AGENT_ID`` + (only known after the A2A Agent is registered). skills default to empty; + :func:`ksadk.a2a_card.build_agent_card` fills a ``general`` skill when none + are provided. + +v2 scope (future, not this module): + :class:`ksadk.a2a.bootstrap.AgentEngineA2ABootstrap` adds JSON-RPC on top + of the same card and the same identity env. The bootstrap replaces this + mount in ``RuntimeAppConfig.a2a`` once shared durable storage and a real + gateway-forward identity verifier are in place. + +Wire 1.0 conformance: + The card is built with :func:`build_agent_card` (same factory used by the + full A2A server), so ``supportedInterfaces`` / ``protocolVersion`` / + ``defaultInputModes`` / ``defaultOutputModes`` match wire 1.0 exactly. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional, Sequence + +from a2a.types import AgentSkill +from fastapi import FastAPI + +from ksadk.a2a_card import build_agent_card + + +def _env(name: str) -> str: + return os.getenv(name, "").strip() + + +@dataclass(frozen=True) +class ManagedA2ACardConfig: + """Database-free configuration for the managed discovery-only route.""" + + enabled: bool = True + base_url: str = "http://127.0.0.1:8000" + agent_name: str = "agent" + description: str = "" + version: str = "0.1.0" + skills: Sequence[str | AgentSkill] = () + streaming: bool = False + prefer_stream: bool = False + + +@dataclass(frozen=True) +class ManagedA2ACardMount: + """Discovery-only card endpoint; mounted by ``RuntimeAppConfig.a2a``. + + Held as a plain dataclass so ``factory._wire_a2a_if_enabled`` can isinstance + it without importing the (optional) ``AgentEngineA2ABootstrap`` class. + """ + + config: ManagedA2ACardConfig + + def mount(self, app: FastAPI) -> None: + """Mount ``GET /.well-known/agent-card.json`` into ``app``. + + Directly registers a FastAPI route returning the card JSON, avoiding + ``a2a.server.routes`` (which pulls in the full A2A server stack and its + ``culsans`` dependency). The response body is the same AgentCard JSON + that ``create_agent_card_routes`` would produce. + """ + card = build_agent_card( + name=self.config.agent_name, + base_url=self.config.base_url, + description=self.config.description, + version=self.config.version, + skills=self.config.skills or (), + streaming=self.config.streaming, + ) + + def _card_to_dict() -> dict: + # a2a.types.AgentCard is a protobuf message; serialize to the same + # camelCase JSON wire format the a2a-sdk route produces. + from google.protobuf.json_format import MessageToDict + + return MessageToDict( + card, + preserving_proto_field_name=False, + use_integers_for_enums=False, + always_print_fields_with_no_presence=True, + ) + + + from fastapi import Response + from fastapi.responses import JSONResponse + + card_payload = _card_to_dict() + + async def _get_agent_card() -> Response: + return JSONResponse(content=card_payload) + + app.add_api_route( + "/.well-known/agent-card.json", + _get_agent_card, + methods=["GET"], + name="a2a_agent_card", + include_in_schema=False, + ) + + async def start(self) -> None: + """No stores/middleware to initialize in v1.""" + + return None + + async def stop(self) -> None: + return None + + +def build_managed_a2a_card_if_configured() -> Optional[ManagedA2ACardMount]: + """Read the A2A identity env vars and build a discovery-only card mount. + + Returns ``None`` when the runtime is not A2A-capable + (``KSADK_A2A_RUNTIME_ID`` empty), so ``run_server`` skips mounting entirely. + Returns a mount even when ``KSADK_A2A_AGENT_ID`` is absent: the discovery + card is intentionally available *before* the A2A Agent is registered. + + Name/version fallback chain: + ``KSADK_A2A_AGENT_NAME`` → ``AGENTENGINE_MANAGED_RUNTIME_NAME`` → + ``KSADK_A2A_RUNTIME_ID``. + """ + runtime_id = _env("KSADK_A2A_RUNTIME_ID") + if not runtime_id: + return None + + base_url = _env("KSADK_A2A_INTERNAL_BASE_URL") or "http://localhost:8080" + name = _env("KSADK_A2A_AGENT_NAME") or _env("AGENTENGINE_MANAGED_RUNTIME_NAME") or runtime_id + version = _env("KSADK_A2A_AGENT_VERSION") or "0.1.0" + + config = ManagedA2ACardConfig( + enabled=True, + base_url=base_url, + agent_name=name, + description="", + version=version, + skills=(), + streaming=False, + prefer_stream=False, + ) + return ManagedA2ACardMount(config) + + +__all__ = [ + "ManagedA2ACardConfig", + "ManagedA2ACardMount", + "build_managed_a2a_card_if_configured", +] diff --git a/ksadk/managed_runtime.py b/ksadk/managed_runtime.py index c5e81bfa..eea52a8c 100644 --- a/ksadk/managed_runtime.py +++ b/ksadk/managed_runtime.py @@ -131,7 +131,9 @@ def validate_runtime_binary(resolved: ResolvedRuntime) -> str: if resolved.name != "codex": return "" try: - from codex_cli_bin import bundled_codex_path + from codex_cli_bin import ( # type: ignore[import-not-found, import-untyped] + bundled_codex_path, + ) codex_bin = bundled_codex_path() completed = subprocess.run( @@ -162,17 +164,22 @@ async def resolve_local_managed_runtime( """Resolve and verify the native runtime used by ``ksadk web``.""" name, requested_version = runtime_config(config) if requested_version: - resolved = ResolvedRuntime(name=name, version=requested_version, source="manifest") - validate_installed_runtime(resolved) - return resolved + explicit_runtime = ResolvedRuntime( + name=name, + version=requested_version, + source="manifest", + ) + validate_installed_runtime(explicit_runtime) + return explicit_runtime + catalog_runtime: ResolvedRuntime | None = None try: - resolved = await resolve_managed_runtime(config, region=region) + catalog_runtime = await resolve_managed_runtime(config, region=region) except ManagedRuntimeError: - resolved = None - if resolved is not None: - validate_installed_runtime(resolved) - return resolved + pass + if catalog_runtime is not None: + validate_installed_runtime(catalog_runtime) + return catalog_runtime installed = installed_runtime_version(name) if not installed: diff --git a/ksadk/memory/adk/backends/sdk_ltm_backend.py b/ksadk/memory/adk/backends/sdk_ltm_backend.py index d227de3c..1bb14b6d 100644 --- a/ksadk/memory/adk/backends/sdk_ltm_backend.py +++ b/ksadk/memory/adk/backends/sdk_ltm_backend.py @@ -8,8 +8,9 @@ 环境变量: KSADK_LTM_BACKEND: sdk - KSADK_LTM_ACCESS_KEY: AK (可选,默认取 KSYUN_ACCESS_KEY) - KSADK_LTM_SECRET_KEY: SK (可选,默认取 KSYUN_SECRET_KEY) + KSADK_LTM_ACCESS_KEY: AK (可选,默认取 KSYUN_ACCESS_KEY / KSYUN_ACCESS_KEY_ID) + KSADK_LTM_SECRET_KEY: SK (可选,默认取 KSYUN_SECRET_KEY / KSYUN_SECRET_ACCESS_KEY) + KSADK_LTM_SESSION_TOKEN: STS 临时会话 token (可选,默认取 KSYUN_SESSION_TOKEN) KSADK_LTM_REGION: 区域 (默认 cn-beijing-6) KSADK_LTM_ENDPOINT: API 端点 (默认 aicp.api.ksyun.com) KSADK_LTM_SCHEME: http/https (默认 https) @@ -42,6 +43,7 @@ class SdkLTMBackend(BaseLongTermMemoryBackend): Attributes: access_key: 访问密钥 ID (AK) secret_key: 访问密钥 (SK) + session_token: STS 临时会话 token region: API 区域 endpoint: API 端点 scheme: http 或 https @@ -64,6 +66,7 @@ class SdkLTMBackend(BaseLongTermMemoryBackend): access_key: str = "" secret_key: str = "" + session_token: str = "" region: str = "cn-beijing-6" endpoint: str = "aicp.api.ksyun.com" scheme: str = "https" @@ -137,7 +140,9 @@ def _get_client(self): "Ensure kingsoftcloud-sdk-python>=1.5.8.94 is installed." ) - cred = credential.Credential(self.access_key, self.secret_key) + cred = credential.Credential( + self.access_key, self.secret_key, self.session_token or None + ) http_profile = HttpProfile() http_profile.endpoint = self.endpoint @@ -240,6 +245,8 @@ def save_memory(self, user_id: str, event_strings: list[str], **kwargs) -> bool: params["AgentId"] = agent_id if session_id: params["SessionId"] = session_id + if metadata.get("flush") is True or kwargs.get("flush") is True: + params["Flush"] = True logger.info( f"CreateMemorySdk: memory_collection_id={memory_collection_id}, " diff --git a/ksadk/memory/adk/long_term_memory.py b/ksadk/memory/adk/long_term_memory.py index 4917a169..30b940fc 100644 --- a/ksadk/memory/adk/long_term_memory.py +++ b/ksadk/memory/adk/long_term_memory.py @@ -246,8 +246,9 @@ def from_env( KSADK_LTM_BACKEND: local / http / sdk KSADK_LTM_HTTP_URL: HTTP 记忆服务地址 KSADK_LTM_HTTP_TOKEN: HTTP 认证 Token - KSADK_LTM_ACCESS_KEY: SDK AK (fallback to KSYUN_ACCESS_KEY) - KSADK_LTM_SECRET_KEY: SDK SK (fallback to KSYUN_SECRET_KEY) + KSADK_LTM_ACCESS_KEY: SDK AK (fallback to KSYUN_ACCESS_KEY / KSYUN_ACCESS_KEY_ID) + KSADK_LTM_SECRET_KEY: SDK SK (fallback to KSYUN_SECRET_KEY / KSYUN_SECRET_ACCESS_KEY) + KSADK_LTM_SESSION_TOKEN: SDK STS token (fallback to KSYUN_SESSION_TOKEN) KSADK_LTM_TOP_K: 检索数量 (默认 5) KSADK_LTM_INDEX: 索引名称 """ diff --git a/ksadk/memory/service.py b/ksadk/memory/service.py index 984e1001..9675f59f 100644 --- a/ksadk/memory/service.py +++ b/ksadk/memory/service.py @@ -77,10 +77,18 @@ def from_env( connection = resolve_aicp_connection("KSADK_LTM") backend_config = { "access_key": ( - os.environ.get("KSADK_LTM_ACCESS_KEY") or os.environ.get("KSYUN_ACCESS_KEY", "") + os.environ.get("KSADK_LTM_ACCESS_KEY") + or os.environ.get("KSYUN_ACCESS_KEY") + or os.environ.get("KSYUN_ACCESS_KEY_ID", "") ), "secret_key": ( - os.environ.get("KSADK_LTM_SECRET_KEY") or os.environ.get("KSYUN_SECRET_KEY", "") + os.environ.get("KSADK_LTM_SECRET_KEY") + or os.environ.get("KSYUN_SECRET_KEY") + or os.environ.get("KSYUN_SECRET_ACCESS_KEY", "") + ), + "session_token": ( + os.environ.get("KSADK_LTM_SESSION_TOKEN") + or os.environ.get("KSYUN_SESSION_TOKEN", "") ), "region": connection["region"], "endpoint": connection["endpoint"], diff --git a/ksadk/memory/tool.py b/ksadk/memory/tool.py index c95ff8fe..73b8f85d 100644 --- a/ksadk/memory/tool.py +++ b/ksadk/memory/tool.py @@ -78,6 +78,10 @@ def save_memory(content: str) -> dict: "agent_id": context.agent_id, "session_id": context.session_id, "runner_type": context.runner_type, + # Explicit user saves must become searchable promptly. The + # SDK backend maps this to CreateMemorySdk.Flush; background + # turn persistence keeps the service's batched default. + "flush": True, }, ) if ok: diff --git a/ksadk/model_proxy/config.py b/ksadk/model_proxy/config.py index dd49b6e7..5b9faec5 100644 --- a/ksadk/model_proxy/config.py +++ b/ksadk/model_proxy/config.py @@ -1,7 +1,8 @@ """ProxyConfig:转换 proxy 的配置(显式注入,不在模块导入时读 env,便于内化与测试)。""" import os -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any, Callable from urllib.parse import urlsplit # HTTPS(凭证安全):不再用明文 HTTP 携带 Bearer key。 @@ -15,17 +16,37 @@ class ProxyConfig: upstream_base: str = DEFAULT_UPSTREAM_BASE api_key: str = "" # 上游凭证(proxy -> 上游) local_token: str = "" # 本地 proxy 鉴权 token(codex -> proxy);空则不校验(仅本地调试) + # 非空时 responses 转换路径强制改写 model(Codex 可能发送内部伪模型名)。 + upstream_model: str = "" timeout: float = 180.0 + event_callback: Callable[[str, dict[str, Any]], None] | None = field( + default=None, + repr=False, + compare=False, + ) def __post_init__(self) -> None: self.upstream_base = _require_secure_upstream(self.upstream_base.rstrip("/")) + def emit(self, event: str, data: dict[str, Any]) -> None: + callback = self.event_callback + if callback is None: + return + try: + callback(event, data) + except Exception: + # Observability must never break model traffic. + return + @classmethod def from_env(cls) -> "ProxyConfig": return cls( upstream_base=os.environ.get("UPSTREAM_BASE", DEFAULT_UPSTREAM_BASE), api_key=os.environ.get("KSPMAS_API_KEY", ""), local_token=os.environ.get("KSADK_PROXY_TOKEN", ""), + upstream_model=os.environ.get("OPENAI_MODEL_NAME") + or os.environ.get("MODEL_NAME") + or "", timeout=float(os.environ.get("UPSTREAM_TIMEOUT", "180")), ) diff --git a/ksadk/model_proxy/namespace.py b/ksadk/model_proxy/namespace.py index a629f338..778b0be7 100644 --- a/ksadk/model_proxy/namespace.py +++ b/ksadk/model_proxy/namespace.py @@ -23,6 +23,7 @@ from __future__ import annotations import hashlib +import json from typing import Any CHAT_TOOL_NAME_MAX_LEN = 64 @@ -64,7 +65,19 @@ def build_restore_map(tools: list[Any] | None) -> dict[str, dict[str, str]]: if not tools: return restore for tool in tools: - if not isinstance(tool, dict) or tool.get("type") != "namespace": + if not isinstance(tool, dict): + continue + if tool.get("type") == "custom": + # custom 工具(如 apply_patch freeform)拍平为普通 function 转发, + # 响应侧据此把 function_call 还原成 custom_tool_call + custom_name = (tool.get("name") or "").strip() + if custom_name: + restore.setdefault( + custom_name, + {"custom": "true", "namespace": "", "name": custom_name}, + ) + continue + if tool.get("type") != "namespace": continue namespace = (tool.get("name") or "").strip() if not namespace: @@ -168,6 +181,21 @@ def restore_function_call(item: dict, restore_map: dict[str, dict[str, str]]) -> flat = item.get("name") entry = restore_map.get(flat) if isinstance(flat, str) else None if entry: + if entry.get("custom") == "true": + # function_call(JSON arguments) -> custom_tool_call(原始文本 input) + raw_args = item.pop("arguments", "") or "" + try: + payload = json.loads(raw_args) + text_input = ( + payload.get("input", raw_args) if isinstance(payload, dict) else raw_args + ) + except Exception: + text_input = raw_args + item["type"] = "custom_tool_call" + item["name"] = entry["name"] + item["input"] = text_input + item.pop("namespace", None) + return item item["name"] = entry["name"] item["namespace"] = entry["namespace"] return item diff --git a/ksadk/model_proxy/server.py b/ksadk/model_proxy/server.py index f2afe5c8..2881f875 100644 --- a/ksadk/model_proxy/server.py +++ b/ksadk/model_proxy/server.py @@ -23,6 +23,54 @@ logger = logging.getLogger(__name__) +def _request_id(response: httpx.Response) -> str: + for name in ( + "x-request-id", + "request-id", + "x-ks-request-id", + "x-amzn-requestid", + ): + value = response.headers.get(name) + if value: + return str(value) + return "" + + +def _reported_usage(raw: object) -> dict[str, int] | None: + if not isinstance(raw, dict): + return None + prompt_details = raw.get("prompt_tokens_details") or {} + completion_details = raw.get("completion_tokens_details") or {} + return { + "inputTokens": int(raw.get("prompt_tokens") or 0), + "outputTokens": int(raw.get("completion_tokens") or 0), + "totalTokens": int(raw.get("total_tokens") or 0), + "cachedInputTokens": int(prompt_details.get("cached_tokens") or 0), + "reasoningOutputTokens": int(completion_details.get("reasoning_tokens") or 0), + } + + +def _emit_completed( + config: ProxyConfig, + *, + response_id: str, + model: object, + status_code: int, + started: float, + usage: object = None, +) -> None: + payload: dict[str, object] = { + "responseId": response_id, + "model": str(model or ""), + "statusCode": status_code, + "durationMs": max(0, int((time.monotonic() - started) * 1000)), + } + reported = _reported_usage(usage) + if reported is not None: + payload["usage"] = reported + config.emit("proxy.completed", payload) + + def _upstream_error(status_code: int) -> JSONResponse: """Return a stable public error without relaying an upstream traceback.""" return JSONResponse( @@ -105,26 +153,75 @@ async def responses(req: Request): content={ "error": { "type": "unsupported_tools", - "message": "The request uses tools unsupported by the model upstream.", + "message": f"The request uses tools unsupported by the model upstream: {e}", } }, ) + # codex 会对内建能力发内部伪模型名(如 auto_review guardian 用 codex-auto-review), + # 单上游代理必须落回配置的真实模型,否则上游按未知模型 403。 + if config.upstream_model and chat_req.get("model") != config.upstream_model: + logger.debug( + "responses model rewrite: %s -> %s", + chat_req.get("model"), + config.upstream_model, + ) + chat_req["model"] = config.upstream_model rid = "resp_" + uuid.uuid4().hex[:24] model = body.get("model") + started = time.monotonic() + config.emit( + "proxy.requested", + { + "responseId": rid, + "model": str(model or ""), + "protocol": "responses-to-chat", + "stream": bool(body.get("stream")), + }, + ) if body.get("stream"): chat_req["stream"] = True chat_req["stream_options"] = {"include_usage": True} return StreamingResponse( - _stream_gen(config, base, headers, chat_req, rid, model, cancel, restore_map), + _stream_gen( + config, + base, + headers, + chat_req, + rid, + model, + cancel, + restore_map, + started, + ), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) async with httpx.AsyncClient(timeout=config.timeout) as c: r = await c.post(f"{base}/chat/completions", json=chat_req, headers=headers) + config.emit( + "proxy.upstream", + {"requestId": _request_id(r), "statusCode": r.status_code}, + ) if r.status_code != 200: + _emit_completed( + config, + response_id=rid, + model=model, + status_code=r.status_code, + started=started, + ) logger.warning("responses upstream rejected request: status=%s", r.status_code) return _upstream_error(r.status_code) - return JSONResponse(chat_to_response(r.json(), rid, restore_map)) + upstream_payload = r.json() + _emit_completed( + config, + response_id=rid, + model=model, + status_code=r.status_code, + started=started, + usage=upstream_payload.get("usage"), + ) + return JSONResponse(chat_to_response(upstream_payload, rid, restore_map)) return app @@ -152,7 +249,9 @@ async def _stream_gen( model, cancel: threading.Event, restore_map: dict | None = None, + started: float | None = None, ): + started = time.monotonic() if started is None else started s = Streamer(rid, model, restore_map) for e in s.start(): yield e @@ -162,8 +261,28 @@ async def _stream_gen( async with c.stream( "POST", f"{base}/chat/completions", json=chat_req, headers=headers ) as r: + config.emit( + "proxy.upstream", + {"requestId": _request_id(r), "statusCode": r.status_code}, + ) if r.status_code != 200: - logger.warning("responses upstream stream rejected: status=%s", r.status_code) + _emit_completed( + config, + response_id=rid, + model=model, + status_code=r.status_code, + started=started, + ) + logger.warning( + "responses upstream stream rejected: status=%s model=%s tools=%s " + "msgs=%s bytes=%s has_text_format=%s", + r.status_code, + chat_req.get("model"), + len(chat_req.get("tools") or []), + len(chat_req.get("messages") or []), + len(json.dumps(chat_req)), + bool(chat_req.get("response_format")), + ) yield Streamer.ev( "response.failed", { @@ -177,6 +296,14 @@ async def _stream_gen( return async for line in r.aiter_lines(): if cancel.is_set(): + _emit_completed( + config, + response_id=rid, + model=model, + status_code=499, + started=started, + usage=s.usage, + ) return # stop() 置位:主动中断活动 SSE,让线程能及时回收 line = line.strip() if not line.startswith("data:"): @@ -191,6 +318,14 @@ async def _stream_gen( for e in s.handle(chunk): yield e except Exception as exc: # noqa: BLE001 + _emit_completed( + config, + response_id=rid, + model=model, + status_code=502, + started=started, + usage=s.usage, + ) logger.warning("responses upstream stream failed", exc_info=exc) yield Streamer.ev( "response.failed", @@ -205,6 +340,14 @@ async def _stream_gen( return for e in s.finalize(): yield e + _emit_completed( + config, + response_id=rid, + model=model, + status_code=200, + started=started, + usage=s.usage, + ) class ProxyServer: diff --git a/ksadk/model_proxy/transform.py b/ksadk/model_proxy/transform.py index fe979ddd..a10d36a1 100644 --- a/ksadk/model_proxy/transform.py +++ b/ksadk/model_proxy/transform.py @@ -122,6 +122,30 @@ def flush(): }, } ) + elif t == "custom_tool_call": + # 历史里的 custom_tool_call:还原为 function 调用(input 包进 JSON) + pending_tc.append( + { + "id": item.get("call_id") or f"call_{len(pending_tc)}", + "type": "function", + "function": { + "name": item.get("name"), + "arguments": json.dumps( + {"input": item.get("input") or ""}, ensure_ascii=False + ), + }, + } + ) + elif t == "custom_tool_call_output": + flush() + out = item.get("output") + messages.append( + { + "role": "tool", + "tool_call_id": item.get("call_id") or "", + "content": out if isinstance(out, str) else json.dumps(out, ensure_ascii=False), + } + ) elif t == "function_call_output": flush() out = item.get("output") @@ -155,9 +179,29 @@ def convert_tools(tools): if "strict" in t: fn["strict"] = t["strict"] out.append({"type": "function", "function": fn}) + elif isinstance(t, dict) and t.get("type") == "custom" and t.get("name"): + # custom 工具(apply_patch 等 freeform 输入)转为单 input 字符串参数的 function + out.append( + { + "type": "function", + "function": { + "name": t.get("name"), + "description": t.get("description", ""), + "parameters": { + "type": "object", + "properties": {"input": {"type": "string"}}, + "required": ["input"], + }, + }, + } + ) else: t = t or {} - unsupported.append(t.get("type") or t.get("name") or "unknown") + import json as _json + try: + unsupported.append(_json.dumps(t, ensure_ascii=False)[:300]) + except Exception: + unsupported.append(str(t.get("type") or t.get("name") or "unknown")) if unsupported: raise UnsupportedToolsError( f"不支持转换的 codex 工具类型: {unsupported};" diff --git a/ksadk/runners/adk_runner.py b/ksadk/runners/adk_runner.py index 044b63fe..85353527 100644 --- a/ksadk/runners/adk_runner.py +++ b/ksadk/runners/adk_runner.py @@ -10,7 +10,6 @@ import inspect import logging import os -import sys from dataclasses import dataclass from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version @@ -24,6 +23,7 @@ from ksadk.conversations.model_context import supports_native_image_input from ksadk.runners.base_runner import BaseRunner from ksadk.runners.usage_accumulator import accumulate_usage +from ksadk.runners.utils import load_agent_module from ksadk.sessions.continuity import ADKSessionAdapter logger = logging.getLogger(__name__) @@ -910,26 +910,12 @@ def load_agent(self) -> None: self._apply_json_patch() self._apply_mcp_result_patch() - # 添加项目目录到 Python 路径 - project_path = Path(self.project_dir).resolve() - if str(project_path) not in sys.path: - sys.path.insert(0, str(project_path)) - - # 确定模块名: 从 entry_point 获取 - # (e.g. "smart_assistant_adk/agent.py" -> "smart_assistant_adk.agent") - entry_point = self.detection_result.entry_point - if entry_point.endswith(".py"): - module_name = entry_point[:-3] # 移除 .py 后缀 - else: - module_name = entry_point - - # 转换路径为模块路径 (e.g., "subdir/agent" -> "subdir.agent") - module_name = module_name.replace("/", ".").replace("\\", ".") - try: - module = __import__(module_name, fromlist=[self.detection_result.agent_variable]) - self._module = module - self._agent = getattr(module, self.detection_result.agent_variable) + self._agent, self._module = load_agent_module( + self.project_dir, + self.detection_result.entry_point, + self.detection_result.agent_variable, + ) # Inject safety instruction for DeepSeek/LLMs to prevent empty tool names if hasattr(self._agent, "instruction"): @@ -939,12 +925,8 @@ def load_agent(self) -> None: else: self._agent.instruction = safety_prompt - except ImportError as e: - raise ImportError(f"无法导入模块 {module_name}: {e}") - except AttributeError: - raise AttributeError( - f"模块 {module_name} 中未找到 {self.detection_result.agent_variable}" - ) + except (ImportError, AttributeError): + raise except Exception as exc: name_error = self._invalid_agent_name_load_error(exc) if name_error is not None: diff --git a/ksadk/runners/base_runner.py b/ksadk/runners/base_runner.py index a36a5f98..5903147d 100644 --- a/ksadk/runners/base_runner.py +++ b/ksadk/runners/base_runner.py @@ -7,6 +7,7 @@ import inspect import os from abc import ABC, abstractmethod +from pathlib import Path from typing import Any, AsyncIterator, Dict, Mapping, Optional from ksadk.sessions.continuity import RunnerSessionAdapter, TranscriptReplayAdapter @@ -310,13 +311,37 @@ def run_server(self, port: int = 8000) -> None: import uvicorn from ksadk.agui.config import default_agui_config + from ksadk.managed_a2a_card import build_managed_a2a_card_if_configured + from ksadk.runtime import RuntimeExecutor, RuntimeLaunchContext, RuntimeRegistry + from ksadk.runtime.runner_adapter import RunnerRuntimeAdapter from ksadk.server.composition import configure_runtime_app from ksadk.server.factory import RuntimeAppConfig, create_runtime_app - # goal-01/16:经 create_runtime_app 注入 runner(per-app state),替代全局 ``app`` + - # ``set_runner`` 全局态。普通 runtime app 装配全部 route group。 + detected_runtime_type = str( + getattr(getattr(self.detection_result, "type", None), "value", "runner") + ) + launch_context = RuntimeLaunchContext( + runtime_type=detected_runtime_type, + project_dir=Path(self.project_dir), + detection=self.detection_result, + ) + runtime_type = launch_context.runtime_type + registry = RuntimeRegistry() + registry.register( + runtime_type, + lambda _context: RunnerRuntimeAdapter(self, runtime_type=runtime_type), + ) + # BaseRunner 仍可作为底层框架实现启动 Web,但 HTTP 层只消费 RuntimeAdapter。 + # managed A2A discovery-only card:KSADK_A2A_RUNTIME_ID 非空时挂 + # ``/.well-known/agent-card.json``;注册前即可被 server 探测(a2a-runtime-inbound-wiring)。 + # KSADK_A2A_AGENT_ID 是注册后注入的不透明注册 ID;v2 JSON-RPC 绑定用,v1 card 不依赖。 app = create_runtime_app( - RuntimeAppConfig(runner=self, agui=default_agui_config(self)), + RuntimeAppConfig( + runtime_executor=RuntimeExecutor(registry), + launch_context=launch_context, + agui=default_agui_config(self), + a2a=build_managed_a2a_card_if_configured(), + ), configure_runtime_app, ) uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/ksadk/runners/codex_runner.py b/ksadk/runners/codex_runner.py deleted file mode 100644 index a07a2021..00000000 --- a/ksadk/runners/codex_runner.py +++ /dev/null @@ -1,132 +0,0 @@ -"""CodexRunner — 把 codex runtime(CodexRuntime,RuntimeAdapter)适配成 BaseRunner。 - -让 codex 项目能像 ADK/LangGraph 一样经 `ksadk web` 起 hosted UI:``create_runner`` -返回本 runner → ``run_server`` 走 ``create_runtime_app`` → /run_sse 与 AGUI 都走它。 - -适配方向:``CodexRuntime.stream`` 产出 ``RuntimeEvent``(TEXT_DELTA/RUN_COMPLETED/…), -本 runner 反向投射成 ADK-style 扁平 dict(``/run_sse`` 期望的 type 词表 -text/thinking/interrupt/final),与 ``ADKRunner.stream`` 产出形状一致。AGUI 路径经 -``RunnerRuntimeAdapter`` 把本 runner 包回 ``RuntimeAdapter``(``_chunk_to_event`` 已覆盖 -全部 type),无需新代码。 - -codex thread 是 ephemeral(``ksadk/codex/client.py`` ``ephemeral=True``),不支持 resume。 -""" - -from __future__ import annotations - -import asyncio -import uuid -from typing import Any, AsyncIterator, Dict - -from ksadk.codex.client import AsyncCodexClient -from ksadk.codex.runtime import CodexRuntime -from ksadk.events.runtime_event import EventType -from ksadk.runners.base_runner import BaseRunner -from ksadk.runtime.adapter import RunHandle, StartRequest - - -class CodexRunner(BaseRunner): - """codex 的 BaseRunner 适配。""" - - def __init__(self, detection_result: Any, project_dir: str) -> None: - super().__init__(detection_result, project_dir) - # opt-in 代理(env KSADK_CODEX_USE_PROXY=1)在 client 构造时自动注入 provider - self._client = AsyncCodexClient() - self._runtime = CodexRuntime(self._client, sandbox_read_only=True) - # invocation_id -> RunHandle(stream 期间持有,cancel 用) - self._handles: dict[str, RunHandle] = {} - - def load_agent(self) -> None: - # codex 无 root_agent 变量;agent 逻辑由 prompt 承载,无需加载 - self._agent = True - - async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: - output = "" - usage: dict[str, Any] = {} - async for chunk in self.stream(input_data): - if chunk.get("type") == "final": - output = chunk.get("output", "") or output - if chunk.get("usage"): - usage = chunk["usage"] - return {"output": output, "usage": usage} - - def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: - return self._stream(input_data) - - async def _stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: - prompt = str(input_data.get("input") or "") - session_id = str(input_data.get("session_id") or uuid.uuid4().hex) - # model:本轮请求优先,fallback 到 yaml(raw_config);prompt:codex 的 base_instructions - raw = getattr(self.detection_result, "raw_config", None) or {} - model = input_data.get("model") or raw.get("model") - base_instructions = raw.get("prompt") - if model: - self.sync_process_model_env(str(model)) - config: Dict[str, Any] = {"sandbox_read_only": True} - if base_instructions: - config["base_instructions"] = str(base_instructions) - request = StartRequest( - input=prompt, - user_id="local", - session_id=session_id, - model=str(model) if model else None, - config=config, - ) - handle = await self._runtime.start(request) - self._handles[handle.run_id] = handle - try: - accumulated = "" # TEXT_DELTA 累积(流式增量,前端打字机用) - completed_text = "" # 最后一次 TEXT_COMPLETED 的全文(权威,防 delta 丢包) - final_sent = False - async for event in self._runtime.stream(handle): - et = event.event_type - payload = event.payload or {} - if et == EventType.TEXT_DELTA: - phase = event.phase or "commentary" - delta = payload.get("text", "") - # final_answer 的 delta 当 text 流(前端打字机);commentary 当 thinking - if phase == "final_answer": - accumulated += delta - yield {"type": "text", "delta": delta} - else: - yield {"type": "thinking", "delta": delta} - elif et == EventType.TEXT_COMPLETED: - # 收尾全文:权威,记下来给 final 用;不当 delta 发(前端已通过 delta 收到)。 - # 只收 final_answer(真正的回复);commentary completed 是思考收尾,忽略。 - phase = event.phase or "final_answer" - if phase == "final_answer": - completed_text = payload.get("text", "") - elif et == EventType.RUN_COMPLETED: - final_sent = True - # final.output 优先用 completed 全文,fallback accumulated(delta 流) - yield {"type": "final", "output": completed_text or accumulated} - elif et == EventType.RUN_FAILED: - final_sent = True - yield {"type": "final", "output": completed_text or accumulated, - "error": payload.get("error", "codex run failed")} - elif et == EventType.RUN_INTERRUPTED: - yield {"type": "interrupt", "reason": "interrupted"} - elif et == EventType.RUN_CANCELED: - yield {"type": "interrupt", "reason": "canceled"} - if not final_sent: - yield {"type": "final", "output": completed_text or accumulated} - finally: - self._handles.pop(handle.run_id, None) - - def request_cancel(self, invocation_id: str) -> str: - handle = self._handles.get(invocation_id) - if handle is None: - return "not_running" - result: Any - try: - result = asyncio.run(self._runtime.cancel(handle)) - except RuntimeError: - # 已有事件循环(异步上下文):用独立线程跑 cancel,避免嵌套 asyncio.run - result = asyncio.run(asyncio.to_thread(self._runtime.cancel, handle)) - return str(getattr(result, "value", result)) - - async def close(self) -> None: - try: - await self._client.close() - except Exception: # noqa: BLE001 - pass diff --git a/ksadk/runners/factory.py b/ksadk/runners/factory.py index 5d5aa6d5..898ab853 100644 --- a/ksadk/runners/factory.py +++ b/ksadk/runners/factory.py @@ -77,9 +77,10 @@ def create_runner(detection_result: DetectionResult, project_dir: str) -> BaseRu return DeepAgentsRunner(detection_result, project_dir) elif detection_result.type == FrameworkType.CODEX: - from ksadk.runners.codex_runner import CodexRunner - - return CodexRunner(detection_result, project_dir) + raise ValueError( + "Codex 只支持 RuntimeAdapter 执行链;请使用 " + "ksadk.runtime.create_runtime_adapter" + ) else: raise ValueError(f"不支持的框架类型: {detection_result.type}") diff --git a/ksadk/runners/langgraph_runner.py b/ksadk/runners/langgraph_runner.py index a6c5cc55..1e2562da 100644 --- a/ksadk/runners/langgraph_runner.py +++ b/ksadk/runners/langgraph_runner.py @@ -17,7 +17,7 @@ from ksadk.conversations.reasoning_markup import ReasoningMarkupParser, strip_reasoning_markup from ksadk.runners.base_runner import BaseRunner from ksadk.runners.usage_accumulator import accumulate_usage -from ksadk.runners.utils import get_langfuse_callbacks, get_langfuse_metadata, load_agent_module +from ksadk.runners.utils import load_agent_module from ksadk.sessions.continuity import LangGraphSessionAdapter @@ -27,6 +27,10 @@ class LangGraphRunner(BaseRunner): 透传原生 LangGraph 功能,支持任意 State 格式 """ + # ToolGateway approvals can arise after an otherwise terminal tool call; + # this runner opts into the runtime's semantic follow-up continuation. + supports_gateway_approval_semantic_resume = True + def load_agent(self) -> None: self._load_agent(force_reload=False) @@ -131,14 +135,7 @@ def get_runtime_capabilities(self) -> dict[str, Any]: def _get_config(self, session_id: str) -> dict: """获取运行配置""" - config: dict[str, Any] = {"configurable": {"thread_id": session_id}} - - langfuse_callbacks = get_langfuse_callbacks() - if langfuse_callbacks: - config["callbacks"] = langfuse_callbacks - config["metadata"] = get_langfuse_metadata(session_id) - - return config + return {"configurable": {"thread_id": session_id}} @staticmethod def _extract_langgraph_checkpoint_ref(payload: Dict[str, Any]) -> dict[str, Any]: @@ -188,6 +185,38 @@ def _checkpoint_resume_input( resume_value = {interrupt_id: value} if interrupt_id else value return Command(resume=resume_value) + @staticmethod + def _is_gateway_approval_semantic_resume(value: Any) -> bool: + """Whether ``value`` came from a completed ToolGateway approval. + + Native LangGraph ``interrupt()`` values must keep using + ``Command(resume=...)``. In contrast, a ToolGateway can return an + ``approval_required`` result from an otherwise normal tool call; the + graph then reaches its terminal node before KsADK shows the approval + card. Its approved tool result needs a new semantic graph turn. + """ + + return bool( + isinstance(value, Mapping) + and value.get("_ksadk_gateway_approval_resume") is True + and str(value.get("type") or "") == "function_call_output" + ) + + @staticmethod + def _gateway_approval_follow_up_input() -> str: + """A neutral prompt for the post-approval semantic continuation. + + The actual approval response and durable tool result are already in + the session history. Do not repeat their contents here: project hooks + commonly route by input keywords, and copying a workspace path back + into a synthetic user message can accidentally trigger another tool. + """ + + return ( + "系统已完成此前获批的操作。请基于会话记录中的真实结果,直接向用户说明完成情况;" + "不要重试,也不要再次要求确认。" + ) + @staticmethod def _checkpoint_ref_from_state(state: Any) -> dict[str, Any]: state_config = None @@ -586,6 +615,16 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: resume_payload_provided = bool(payload.pop("resume_payload_provided", False)) resume_interrupt_id = str(payload.pop("resume_interrupt_id", "") or "") resume_value = payload.get("input") + is_gateway_approval_resume = bool( + is_resume and self._is_gateway_approval_semantic_resume(resume_value) + ) + if is_gateway_approval_resume: + # ``Command(resume=...)`` only works for a graph that actually + # yielded LangGraph's native interrupt. ToolGateway approvals are + # intercepted after a normal tool result, when the graph already + # ended, so restart a normal turn from the persisted transcript. + payload["input"] = self._gateway_approval_follow_up_input() + resume_value = payload["input"] checkpoint_ref = self._extract_langgraph_checkpoint_ref(payload) history = payload.pop("history", []) native_context = self.build_native_context(payload.get("platform_context")) @@ -600,14 +639,19 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: # 判断输入格式 / resume if is_checkpoint_resume: state = resume_value - elif is_resume: + elif is_resume and not is_gateway_approval_resume: # ``Command(resume=...)`` is delivered to the graph's suspended # interrupt. A custom prepare-state hook is for new user input; # applying it here can rewrite an approval decision into ordinary # graph state and turn an approved HITL action into a rejection. state = resume_value elif self._has_prepare_state_hook(): - state = self._prepare_state_with_hook(payload, session_id, history) + state = self._prepare_state_with_hook( + payload, + session_id, + history, + is_resume=is_gateway_approval_resume, + ) else: state = self._to_state(payload, history) @@ -622,7 +666,7 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: config=config, context=native_context, ) - elif is_resume: + elif is_resume and not is_gateway_approval_resume: result = await self._invoke_graph( Command(resume=state), config=config, @@ -845,13 +889,21 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An resume_payload_provided = bool(payload.pop("resume_payload_provided", False)) resume_interrupt_id = str(payload.pop("resume_interrupt_id", "") or "") resume_value = payload.get("input") + is_gateway_approval_resume = bool( + is_resume and self._is_gateway_approval_semantic_resume(resume_value) + ) + if is_gateway_approval_resume: + # See ``invoke``: the graph did not suspend at a native interrupt, + # so use the durable transcript to run the post-tool answer turn. + payload["input"] = self._gateway_approval_follow_up_input() + resume_value = payload["input"] checkpoint_ref = self._extract_langgraph_checkpoint_ref(payload) native_context = self.build_native_context(payload.get("platform_context")) invoke_payload = dict(payload) invoke_payload["session_id"] = session_id if history: invoke_payload["history"] = history - if is_resume: + if is_resume and not is_gateway_approval_resume: invoke_payload["resume"] = True if is_checkpoint_resume: invoke_payload["checkpoint_resume"] = True @@ -868,12 +920,17 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An if is_checkpoint_resume: state = resume_value - elif is_resume: + elif is_resume and not is_gateway_approval_resume: # Keep the interrupt value intact for ``Command(resume=...)``; # prepare-state hooks only shape fresh user turns. state = resume_value elif self._has_prepare_state_hook(): - state = self._prepare_state_with_hook(payload, session_id, history) + state = self._prepare_state_with_hook( + payload, + session_id, + history, + is_resume=is_gateway_approval_resume, + ) else: state = self._to_state(payload, history) @@ -971,7 +1028,9 @@ def latest_model_usage() -> dict[str, Any]: interrupt_id=resume_interrupt_id, ) if is_checkpoint_resume - else (Command(resume=state) if is_resume else state) + else ( + Command(resume=state) if is_resume and not is_gateway_approval_resume else state + ) ) # stream_mode 含 "custom" 才会产生 on_custom_stream 事件(custom writer); # 保留默认 "values" 以兼容既有 on_chain_end/graph_update 消费。 @@ -1047,9 +1106,7 @@ def latest_model_usage() -> dict[str, Any]: # ("values", state) 是 state 快照(忽略,终态走 on_chain_end)。 # 编排方常用 custom writer 把"调远端 agent/子图"的流式增量透传出来。 chunk = event.get("data", {}).get("chunk") - if not ( - isinstance(chunk, tuple) and len(chunk) == 2 and chunk[0] == "custom" - ): + if not (isinstance(chunk, tuple) and len(chunk) == 2 and chunk[0] == "custom"): continue data = chunk[1] if isinstance(data, str): @@ -1076,18 +1133,19 @@ def latest_model_usage() -> dict[str, Any]: replace = bool(data.get("replace")) if custom_type == "thinking": accumulated_reasoning = ( - custom_delta - if replace - else accumulated_reasoning + custom_delta + custom_delta if replace else accumulated_reasoning + custom_delta ) else: accumulated_text = ( custom_delta if replace else accumulated_text + custom_delta ) - out = {"delta": custom_delta, "type": custom_type} + custom_event: dict[str, Any] = { + "delta": custom_delta, + "type": custom_type, + } if replace: - out["replace"] = True - yield out + custom_event["replace"] = True + yield custom_event continue if data is not None: accumulated_text += str(data) diff --git a/ksadk/runners/utils/__init__.py b/ksadk/runners/utils/__init__.py index 5ea16045..25689b7e 100644 --- a/ksadk/runners/utils/__init__.py +++ b/ksadk/runners/utils/__init__.py @@ -2,18 +2,8 @@ Runner 公共工具模块 """ -from ksadk.runners.utils.langfuse import ( - get_langfuse_callback, - get_langfuse_callbacks, - get_langfuse_metadata, - prepare_trace_metadata, -) from ksadk.runners.utils.loader import load_agent_module __all__ = [ - "get_langfuse_callback", - "get_langfuse_callbacks", - "get_langfuse_metadata", - "prepare_trace_metadata", "load_agent_module", ] diff --git a/ksadk/runners/utils/langfuse.py b/ksadk/runners/utils/langfuse.py deleted file mode 100644 index 58ecd90c..00000000 --- a/ksadk/runners/utils/langfuse.py +++ /dev/null @@ -1,230 +0,0 @@ -""" -Langfuse 集成工具 - -提供 LangChain/LangGraph Runner 共用的 Langfuse 集成功能 -""" - -import logging -import os -from typing import Any - -logger = logging.getLogger(__name__) - -# 全局缓存 -_langfuse_callback = None -_cloud_monitor_langfuse_callback = None - - -def _env_flag_enabled(name: str) -> bool: - return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} - - -def _env_flag_disabled(name: str) -> bool: - return os.getenv(name, "").strip().lower() in {"0", "false", "no", "off", "disabled"} - - -def _first_env(*names: str) -> str: - for name in names: - value = os.getenv(name, "").strip() - if value: - return value - return "" - - -def _create_langfuse_callback( - *, - label: str, - public_key: str, - secret_key: str = "", - host: str = "", -) -> Any | None: - try: - from langfuse import Langfuse - from langfuse.langchain import CallbackHandler - from opentelemetry.sdk.resources import Resource - from opentelemetry.sdk.trace import TracerProvider - - resource_attributes = {} - service_name = os.getenv("OTEL_SERVICE_NAME", "").strip() - if service_name: - resource_attributes["service.name"] = service_name - tracer_provider = TracerProvider(resource=Resource.create(resource_attributes)) - Langfuse( - public_key=public_key, - secret_key=secret_key or None, - base_url=host or None, - tracer_provider=tracer_provider, - ) - try: - handler = CallbackHandler(public_key=public_key) - except TypeError: - handler = CallbackHandler() - logger.info( - "%s Langfuse CallbackHandler initialized " - "(host: %s public_key_present=%s secret_key_present=%s isolated_provider=True)", - label, - host or "default", - bool(public_key), - bool(secret_key), - ) - return handler - except ImportError as e: - logger.warning("Langfuse not installed for %s callback: %s", label, e) - return None - except Exception as e: - logger.error("Failed to create %s Langfuse CallbackHandler: %s", label, e) - return None - - -def get_langfuse_callback(): - """获取 Langfuse CallbackHandler - - Returns: - CallbackHandler 实例,未配置时返回 None - """ - global _langfuse_callback - - if not _env_flag_enabled("LANGFUSE_USE_CALLBACK"): - logger.debug("Langfuse CallbackHandler disabled; using OTLP direct exporter by default") - return None - - if _langfuse_callback is not None: - return _langfuse_callback - - # 检查是否配置了 Langfuse - public_key = os.getenv("LANGFUSE_PUBLIC_KEY", "").strip() - if not public_key: - return None - - _langfuse_callback = _create_langfuse_callback( - label="Primary", - public_key=public_key, - secret_key=os.getenv("LANGFUSE_SECRET_KEY", "").strip(), - host=_first_env("LANGFUSE_HOST", "LANGFUSE_BASE_URL"), - ) - return _langfuse_callback - - -def get_cloud_monitor_langfuse_callback(): - """获取云监控 Langfuse SDK CallbackHandler。""" - global _cloud_monitor_langfuse_callback - - if not _env_flag_enabled("LANGFUSE_USE_CALLBACK"): - return None - if _env_flag_disabled("CLOUD_MONITOR_LANGFUSE_ENABLED"): - logger.info( - "CloudMonitor Langfuse CallbackHandler disabled by CLOUD_MONITOR_LANGFUSE_ENABLED" - ) - return None - if _cloud_monitor_langfuse_callback is not None: - return _cloud_monitor_langfuse_callback - - public_key = os.getenv("CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY", "").strip() - secret_key = os.getenv("CLOUD_MONITOR_LANGFUSE_SECRET_KEY", "").strip() - host = _first_env("CLOUD_MONITOR_LANGFUSE_HOST", "CLOUD_MONITOR_OTLP_ENDPOINT") - if not public_key and not secret_key and not host: - return None - if not public_key or not secret_key or not host: - logger.warning( - "CloudMonitor Langfuse CallbackHandler skipped: public_key_present=%s " - "secret_key_present=%s host_present=%s", - bool(public_key), - bool(secret_key), - bool(host), - ) - return None - - _cloud_monitor_langfuse_callback = _create_langfuse_callback( - label="CloudMonitor", - public_key=public_key, - secret_key=secret_key, - host=host, - ) - return _cloud_monitor_langfuse_callback - - -def get_langfuse_callbacks() -> list[Any]: - """获取所有 Langfuse CallbackHandler,原平台和云监控各自独立。""" - callbacks = [] - primary = get_langfuse_callback() - if primary: - callbacks.append(primary) - - cloud_monitor = get_cloud_monitor_langfuse_callback() - if cloud_monitor: - callbacks.append(cloud_monitor) - - return callbacks - - -def get_langfuse_metadata(session_id: str | None = None) -> dict[str, Any]: - """获取 Langfuse 的 metadata 字典 - - 通过 metadata 字段传递 trace 属性: - - langfuse_user_id - - langfuse_session_id - - langfuse_tags - - Args: - session_id: 会话 ID (可选) - - Returns: - 包含 Langfuse 属性的 metadata 字典 - """ - metadata: dict[str, Any] = {} - - if session_id: - metadata["langfuse_session_id"] = session_id - - try: - from ksadk.configs import settings - - agent_config = settings.agent - - if agent_config.user_id: - metadata["langfuse_user_id"] = agent_config.user_id - - if not session_id and agent_config.session_id: - metadata["langfuse_session_id"] = agent_config.session_id - - tags = list(agent_config.tags or []) - if agent_config.environment and agent_config.environment not in tags: - tags.append(agent_config.environment) - if agent_config.agent_name and agent_config.agent_name not in tags: - tags.append(agent_config.agent_name) - if tags: - metadata["langfuse_tags"] = tags - - except (ImportError, Exception): - pass - - return metadata - - -def prepare_trace_metadata(session_id: str | None = None) -> tuple[Any, list[Any], Any, Any]: - """准备 Trace 元数据 - - Returns: - (user_id, tags, version, agent_name) 元组 - """ - user_id = None - tags = [] - version = None - agent_name = None - - try: - from ksadk.configs import settings - - agent_config = settings.agent - user_id = agent_config.user_id - tags = list(agent_config.tags or []) - version = agent_config.version - agent_name = agent_config.agent_name - - if agent_config.environment and agent_config.environment not in tags: - tags.append(agent_config.environment) - - except (ImportError, Exception): - pass - - return user_id, tags, version, agent_name diff --git a/ksadk/runners/utils/loader.py b/ksadk/runners/utils/loader.py index d70e172d..f1af61c0 100644 --- a/ksadk/runners/utils/loader.py +++ b/ksadk/runners/utils/loader.py @@ -43,13 +43,29 @@ def load_agent_module( # 确定模块名 if entry_point.endswith(".py"): module_name = entry_point[:-3] + entry_file = (project_path / entry_point).resolve() else: module_name = entry_point + entry_file = None # 路径转换为模块路径 module_name = module_name.replace("/", ".").replace("\\", ".") try: + # Studio 会在同一 Python 进程内运行多个不可变 Bundle。它们通常都以 + # ``agent.py`` 为入口;仅靠 ``import_module("agent")`` 会复用上一个 + # Bundle 的 sys.modules 缓存,进而加载错误的 agentVariable。只复用与 + # 当前 entry 文件一致的缓存,其余情况让 import 从当前 project_path 重载。 + cached = sys.modules.get(module_name) + cached_file = getattr(cached, "__file__", None) + cached_matches_entry = bool( + cached_file is not None + and entry_file is not None + and Path(str(cached_file)).resolve() == entry_file + ) + if cached is not None and not cached_matches_entry: + sys.modules.pop(module_name, None) + importlib.invalidate_caches() if force_reload and module_name in sys.modules: module = importlib.reload(sys.modules[module_name]) else: diff --git a/ksadk/runtime/__init__.py b/ksadk/runtime/__init__.py index 133ab2a3..03b6e8a4 100644 --- a/ksadk/runtime/__init__.py +++ b/ksadk/runtime/__init__.py @@ -1,41 +1,67 @@ -"""RuntimeAdapter 平台接口包 (goal-03 冻结签名 + goal-07 框架实现)。""" - -from ksadk.runtime.adapter import ( - CONVERSATION_PREPROCESSING_METADATA_KEY, - BaseRuntime, - CancelResult, - CheckpointCapability, - CheckpointDescriptor, - ConversationPreprocessingRequest, - ResumePayload, - ResumeTarget, - RunHandle, - RuntimeAdapter, - RuntimeRegistry, - StartRequest, -) -from ksadk.runtime.framework_adapters import ( - ADKRuntimeAdapter, - LangGraphRuntimeAdapter, - build_default_registry, -) -from ksadk.runtime.runner_adapter import RunnerRuntimeAdapter - -__all__ = [ - "ADKRuntimeAdapter", - "BaseRuntime", - "CancelResult", - "CheckpointCapability", - "CheckpointDescriptor", - "ConversationPreprocessingRequest", - "CONVERSATION_PREPROCESSING_METADATA_KEY", - "LangGraphRuntimeAdapter", - "ResumePayload", - "ResumeTarget", - "RunHandle", - "RunnerRuntimeAdapter", - "RuntimeAdapter", - "RuntimeRegistry", - "StartRequest", - "build_default_registry", -] +"""Side-effect-free public exports for the canonical RuntimeAdapter stack.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any + +_EXPORTS = { + "ADKRuntimeAdapter": ("ksadk.runtime.framework_adapters", "ADKRuntimeAdapter"), + "BaseRuntime": ("ksadk.runtime.adapter", "BaseRuntime"), + "CancelResult": ("ksadk.runtime.adapter", "CancelResult"), + "PauseResult": ("ksadk.runtime.adapter", "PauseResult"), + "CheckpointCapability": ("ksadk.runtime.adapter", "CheckpointCapability"), + "CheckpointDescriptor": ("ksadk.runtime.adapter", "CheckpointDescriptor"), + "ConversationPreprocessingRequest": ( + "ksadk.runtime.adapter", + "ConversationPreprocessingRequest", + ), + "CONVERSATION_PREPROCESSING_METADATA_KEY": ( + "ksadk.runtime.adapter", + "CONVERSATION_PREPROCESSING_METADATA_KEY", + ), + "LangGraphRuntimeAdapter": ( + "ksadk.runtime.framework_adapters", + "LangGraphRuntimeAdapter", + ), + "RESUME_START_REQUEST_NATIVE_KEY": ( + "ksadk.runtime.adapter", + "RESUME_START_REQUEST_NATIVE_KEY", + ), + "ResumePayload": ("ksadk.runtime.adapter", "ResumePayload"), + "ResumeTarget": ("ksadk.runtime.adapter", "ResumeTarget"), + "RunHandle": ("ksadk.runtime.adapter", "RunHandle"), + "RunnerRuntimeAdapter": ("ksadk.runtime.runner_adapter", "RunnerRuntimeAdapter"), + "RuntimeAdapter": ("ksadk.runtime.adapter", "RuntimeAdapter"), + "RuntimeAdapterFactory": ("ksadk.runtime.adapter", "RuntimeAdapterFactory"), + "RuntimeExecutor": ("ksadk.runtime.executor", "RuntimeExecutor"), + "RuntimeStartPreparation": ( + "ksadk.runtime.executor", + "RuntimeStartPreparation", + ), + "RuntimeLaunchContext": ("ksadk.runtime.adapter", "RuntimeLaunchContext"), + "RuntimeRegistry": ("ksadk.runtime.adapter", "RuntimeRegistry"), + "RuntimeServices": ("ksadk.runtime.adapter", "RuntimeServices"), + "StartRequest": ("ksadk.runtime.adapter", "StartRequest"), + "build_default_runtime_registry": ( + "ksadk.runtime.factory", + "build_default_runtime_registry", + ), + "create_runtime_adapter": ("ksadk.runtime.factory", "create_runtime_adapter"), +} + +__all__ = list(_EXPORTS) + + +def __getattr__(name: str) -> Any: + try: + module_name, attribute = _EXPORTS[name] + except KeyError: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None + value = getattr(import_module(module_name), attribute) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted({*globals(), *__all__}) diff --git a/ksadk/runtime/adapter.py b/ksadk/runtime/adapter.py index 2d33041e..73ecebc7 100644 --- a/ksadk/runtime/adapter.py +++ b/ksadk/runtime/adapter.py @@ -30,12 +30,14 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Callable from enum import Enum from typing import Any, AsyncIterator, Literal, Optional from pydantic import BaseModel, ConfigDict, Field from ksadk.events.runtime_event import RuntimeEvent +from ksadk.runtime.launch import RuntimeLaunchContext, RuntimeServices # --------------------------------------------------------------------------- # cancel 状态机 @@ -62,6 +64,19 @@ class CancelResult(str, Enum): """取消动作本身失败(如底层 runtime 报错)。""" +class PauseResult(str, Enum): + """Non-terminal pause capability result. + + Pause is deliberately separate from :class:`CancelResult`: an adapter must + never claim a run is resumable after applying destructive cancel semantics. + """ + + PAUSED_ACTIVE_TURN = "paused_active_turn" + NOT_SUPPORTED = "not_supported" + NOT_RUNNING = "not_running" + FAILED = "failed" + + # --------------------------------------------------------------------------- # resume 目标与回包(两件拆开的的事) # --------------------------------------------------------------------------- @@ -137,6 +152,9 @@ class CheckpointDescriptor(BaseModel): CONVERSATION_PREPROCESSING_METADATA_KEY = "conversation_request" """StartRequest metadata key for the shared conversation preprocessing contract.""" +RESUME_START_REQUEST_NATIVE_KEY = "_conversation_start_request" +"""Ephemeral adapter-private key carrying current request context across attach/resume.""" + class ConversationPreprocessingRequest(BaseModel): """Transport-neutral input for the existing conversation preprocessing path. @@ -236,6 +254,18 @@ def __init__(self, runtime: BaseRuntime) -> None: def runtime(self) -> BaseRuntime: return self._runtime + async def preflight(self) -> None: + """Validate that this adapter can accept a new run without creating one. + + This is deliberately an additive lifecycle hook rather than a seventh + platform verb. HTTP streaming routes use it before committing a 200 + response, so a lazy runner import or configuration failure is returned + as a normal request error instead of a detached, half-open SSE stream. + Implementations must not allocate a run handle or start model work. + """ + + return None + @abstractmethod async def start(self, request: StartRequest) -> RunHandle: """启动一次 run,返回句柄。""" @@ -255,6 +285,26 @@ async def cancel(self, handle: RunHandle) -> CancelResult: """请求取消。返回状态机结果;成功 cancel 级联丢弃该 turn 的 pending 审批。""" raise NotImplementedError + async def pause(self, handle: RunHandle) -> PauseResult: + """Pause an active turn without invalidating its resumable state. + + This additive hook defaults to an honest unsupported result. It must + not fall back to ``cancel`` because cancellation is terminal for some + runtimes (notably Codex). + """ + + return PauseResult.NOT_SUPPORTED + + async def submit(self, handle: RunHandle, payload: ResumePayload) -> None: + """Submit input to a live interaction without restarting the stream. + + Runtimes whose HITL model ends the current stream should continue to + use :meth:`resume`; live JSON-RPC approval requests use this command + channel instead. + """ + + raise RuntimeError(f"{type(self).__name__} does not support live interaction input") + @abstractmethod async def resume( self, @@ -298,48 +348,65 @@ async def close(self, handle: RunHandle) -> None: # --------------------------------------------------------------------------- +RuntimeAdapterFactory = Callable[[RuntimeLaunchContext], RuntimeAdapter] + + class RuntimeRegistry: - """按 ``runtime_type`` 注册/查找 :class:`RuntimeAdapter`。 + """按 ``runtime_type`` 注册/创建 :class:`RuntimeAdapter`。 替代 ``runners/factory.py`` 的 if/elif 分发:新 runtime 通过 ``register`` 注册,不再改 factory 分支。 """ def __init__(self) -> None: - self._adapters: dict[str, type[RuntimeAdapter]] = {} + self._factories: dict[str, RuntimeAdapterFactory] = {} - def register(self, runtime_type: str, adapter_cls: type[RuntimeAdapter]) -> None: + def register(self, runtime_type: str, factory: RuntimeAdapterFactory) -> None: if not isinstance(runtime_type, str) or not runtime_type.strip(): - raise ValueError("runtime_type 必须是非空字符串") - if not (isinstance(adapter_cls, type) and issubclass(adapter_cls, RuntimeAdapter)): - raise TypeError(f"adapter_cls 必须是 RuntimeAdapter 子类: {adapter_cls!r}") - self._adapters[runtime_type.strip()] = adapter_cls - - def get(self, runtime_type: str) -> type[RuntimeAdapter]: + raise ValueError("runtime type must be a non-empty string") + key = runtime_type.strip().lower() + if key in self._factories: + raise ValueError(f"duplicate runtime type: {runtime_type!r}") + if not callable(factory): + raise TypeError(f"runtime factory must be callable: {factory!r}") + self._factories[key] = factory + + def get(self, runtime_type: str) -> RuntimeAdapterFactory: + key = runtime_type.strip().lower() try: - return self._adapters[runtime_type] + return self._factories[key] except KeyError: raise KeyError( - f"未注册的 runtime_type: {runtime_type!r}(已注册: {sorted(self._adapters)})" + f"missing runtime type: {runtime_type!r}; registered: {sorted(self._factories)}" ) from None - def create(self, runtime_type: str, runtime: BaseRuntime) -> RuntimeAdapter: - """按 runtime_type 实例化 adapter(注入原生 runtime)。""" - return self.get(runtime_type)(runtime) + def create(self, context: RuntimeLaunchContext) -> RuntimeAdapter: + """使用不可变启动上下文创建一个新的 Adapter 实例。""" + + adapter = self.get(context.runtime_type)(context) + if not isinstance(adapter, RuntimeAdapter): + raise TypeError( + f"runtime factory must return RuntimeAdapter, got {type(adapter).__name__}" + ) + return adapter def registered_types(self) -> list[str]: - return sorted(self._adapters) + return sorted(self._factories) __all__ = [ "BaseRuntime", "CancelResult", + "PauseResult", "CheckpointCapability", "CheckpointDescriptor", "ResumePayload", "ResumeTarget", "RunHandle", "RuntimeAdapter", + "RuntimeAdapterFactory", + "RuntimeLaunchContext", "RuntimeRegistry", + "RuntimeServices", "StartRequest", ] diff --git a/ksadk/runtime/conversation_execution.py b/ksadk/runtime/conversation_execution.py new file mode 100644 index 00000000..e33981c7 --- /dev/null +++ b/ksadk/runtime/conversation_execution.py @@ -0,0 +1,553 @@ +"""Conversation preparation and persistence around the canonical RuntimeExecutor.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable, Mapping, Sequence +from dataclasses import asdict +from typing import Any + +from ksadk.conversations.run_kinds import RUN_MODE_FOREGROUND +from ksadk.conversations.runtime_compaction import preview_auto_compaction +from ksadk.conversations.runtime_metadata import ( + _update_session_metadata_after_assistant_turn, +) +from ksadk.conversations.runtime_persistence import append_run_status_event +from ksadk.conversations.runtime_preparation import build_run_input +from ksadk.events.runtime_event import EventType, RuntimeEvent +from ksadk.events.store import RuntimeEventStore +from ksadk.runtime.adapter import ( + CONVERSATION_PREPROCESSING_METADATA_KEY, + RESUME_START_REQUEST_NATIVE_KEY, + ResumePayload, + ResumeTarget, + RunHandle, + StartRequest, +) +from ksadk.runtime.executor import RuntimeExecutor, RuntimeStartPreparation +from ksadk.runtime.launch import RuntimeLaunchContext +from ksadk.sessions import resolve_session_service + +_TERMINAL_EVENTS = frozenset( + { + EventType.RUN_COMPLETED, + EventType.RUN_FAILED, + EventType.RUN_CANCELED, + } +) + +_RUN_STATUS_BY_EVENT = { + EventType.RUN_STARTED: "in_progress", + EventType.RUN_INTERRUPTED: "interrupted", + EventType.RUN_COMPLETED: "completed", + EventType.RUN_FAILED: "failed", + EventType.RUN_CANCELED: "cancelled", +} + + +async def iter_runtime_conversation_events( + *, + executor: RuntimeExecutor, + launch_context: RuntimeLaunchContext, + agent_id: str, + user_id: str, + messages: Sequence[dict[str, Any]], + session_id: str | None, + model: str | None, + model_metadata: Mapping[str, Any] | None = None, + model_options: Mapping[str, Any] | None = None, + state_delta: dict[str, Any] | None = None, + instructions: str | None = None, + request_metadata: Mapping[str, Any] | None = None, + custom_metadata: Mapping[str, Any] | None = None, + resume_input: Mapping[str, Any] | None = None, + response_id: str | None = None, + account_id: str | None = None, + invocation_id: str | None = None, + session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_FOREGROUND, + runtime_preparation: RuntimeStartPreparation | None = None, +) -> AsyncIterator[RuntimeEvent]: + """Prepare once, execute through RuntimeExecutor, and persist RuntimeEvents.""" + + provider = session_service_provider or resolve_session_service + compaction_preview = await preview_auto_compaction( + agent_id=agent_id, + user_id=user_id, + session_id=session_id, + messages=messages, + model=model, + model_metadata=model_metadata, + session_service_provider=provider, + ) + prepared = await build_run_input( + agent_id=agent_id, + user_id=user_id, + session_id=session_id, + messages=messages, + model=model, + model_metadata=model_metadata, + model_options=model_options, + state_delta=state_delta, + instructions=instructions, + request_metadata=request_metadata, + custom_metadata=custom_metadata, + resume_input=resume_input, + invocation_id=invocation_id, + session_service_provider=provider, + run_mode=run_mode, + ) + canonical_messages = prepared.responses_history or [dict(item) for item in messages] + conversation_request = { + "messages": canonical_messages, + "model_metadata": prepared.model_metadata, + "model_options": prepared.model_options, + "state_delta": dict(state_delta or {}), + "instructions": prepared.instructions, + "request_metadata": prepared.request_metadata, + "custom_metadata": dict(custom_metadata or {}), + "account_id": account_id, + "response_id": response_id, + "prepared_turn": asdict(prepared), + } + request = StartRequest( + input=prepared.user_input, + user_id=user_id, + session_id=prepared.session_id, + agent_id=agent_id, + model=model, + metadata={ + "invocation_id": prepared.invocation_id, + CONVERSATION_PREPROCESSING_METADATA_KEY: conversation_request, + }, + ) + checkpoint_resume = _checkpoint_resume_input(prepared.resume_input) + if checkpoint_resume is None: + handle = await executor.start( + launch_context, + request, + preparation=runtime_preparation, + ) + else: + handle = await _resume_runtime_handle( + executor=executor, + launch_context=launch_context, + session_id=prepared.session_id, + resume_input=checkpoint_resume, + request=request, + ) + request = request.model_copy( + update={ + "metadata": { + **request.metadata, + "invocation_id": handle.run_id, + } + } + ) + store = RuntimeEventStore(provider()) + terminal = False + interrupted = False + completed_assistant_text = "" + try: + for context_event in _compaction_runtime_events( + prepared=prepared, + preview=compaction_preview, + agent_id=agent_id, + user_id=user_id, + ): + yield await store.append_one(context_event) + async for event in executor.stream(handle): + _validate_event_scope(event, request) + persisted = await store.append_one(event) + await _project_runtime_run_status( + persisted, + run_mode=prepared.run_mode, + run_trigger=prepared.run_trigger, + session_service_provider=provider, + ) + if ( + persisted.event_type == EventType.TEXT_COMPLETED + and persisted.phase == "final_answer" + ): + completed_assistant_text = str(persisted.payload.get("text") or "") + terminal = persisted.event_type in _TERMINAL_EVENTS + interrupted = persisted.event_type == EventType.RUN_INTERRUPTED + yield persisted + if not terminal and not interrupted: + raise RuntimeError("runtime stream ended without a terminal or interrupted event") + except asyncio.CancelledError: + cancel_result = await executor.cancel(handle) + cancelled_event = RuntimeEvent.create( + EventType.RUN_CANCELED, + agent_id=str(request.agent_id or ""), + user_id=request.user_id, + session_id=request.session_id, + invocation_id=str(request.metadata["invocation_id"]), + seq_id=0, + payload={ + "status": "cancelled", + "cancel_result": cancel_result.value, + }, + ) + persisted_cancelled = await store.append_one(cancelled_event) + await _project_runtime_run_status( + persisted_cancelled, + run_mode=prepared.run_mode, + run_trigger=prepared.run_trigger, + session_service_provider=provider, + ) + await executor.close(handle) + raise + except BaseException: + if not interrupted: + await executor.close(handle) + raise + else: + if terminal and completed_assistant_text: + await _update_session_metadata_after_assistant_turn( + service=provider(), + session_id=prepared.session_id, + assistant_text=completed_assistant_text, + model=model, + ) + if terminal: + await executor.close(handle) + + +def _compaction_runtime_events( + *, + prepared: Any, + preview: Any, + agent_id: str, + user_id: str, +) -> list[RuntimeEvent]: + if not prepared.compaction_triggered: + return [] + trigger = str(prepared.compaction_trigger or "auto") + scope = { + "agent_id": agent_id, + "user_id": user_id, + "session_id": prepared.session_id, + "invocation_id": prepared.invocation_id, + } + preview_payload = { + "phase": "start", + "trigger": trigger, + "total_chars": preview.total_chars, + "total_estimated_tokens": preview.total_estimated_tokens, + "group_count": preview.group_count, + "threshold_percentage": preview.auto_compact_threshold_percentage, + } + completed_payload = { + **preview_payload, + "phase": "done", + "compacted_until_seq_id": int(prepared.compacted_until_seq_id or 0), + } + return [ + RuntimeEvent.create( + EventType.CONTEXT_COMPACTION_STARTED, + seq_id=0, + payload=preview_payload, + **scope, + ), + RuntimeEvent.create( + EventType.CONTEXT_COMPACTION_COMPLETED, + seq_id=0, + payload=completed_payload, + **scope, + ), + ] + + +async def _project_runtime_run_status( + event: RuntimeEvent, + *, + run_mode: str, + run_trigger: str, + session_service_provider: Callable[[], Any], +) -> None: + """Project canonical lifecycle events into the session's active-run state.""" + + status = _RUN_STATUS_BY_EVENT.get(event.event_type) + if status is None: + return + detail = event.payload.get("error") or event.payload.get("detail") + await append_run_status_event( + session_id=event.session_id, + author=event.agent_id, + status=status, + invocation_id=event.invocation_id, + detail=str(detail) if detail else None, + metadata={ + "runtime_event_id": event.event_id, + "runtime_event_type": event.event_type, + }, + session_service_provider=session_service_provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + + +def _checkpoint_resume_input(value: Any) -> dict[str, Any] | None: + if not isinstance(value, Mapping): + return None + if str(value.get("type") or "").strip() != "agentengine.resume_checkpoint": + return None + return dict(value) + + +async def _resume_runtime_handle( + *, + executor: RuntimeExecutor, + launch_context: RuntimeLaunchContext, + session_id: str, + resume_input: Mapping[str, Any], + request: StartRequest, +) -> RunHandle: + run_id = str(resume_input.get("run_id") or "").strip() + checkpoint_id = str(resume_input.get("checkpoint_id") or "").strip() + if not run_id or not checkpoint_id: + raise ValueError("checkpoint resume requires run_id and checkpoint_id") + + handle = executor.find_handle(launch_context.runtime_type, run_id, session_id) + if handle is None: + native_ref = _persisted_resume_native_ref(resume_input) + native_ref.update({"agent_id": request.agent_id, "user_id": request.user_id}) + handle = RunHandle( + run_id=run_id, + session_id=session_id, + runtime_type=launch_context.runtime_type, + native_ref=native_ref, + ) + handle.native_ref[RESUME_START_REQUEST_NATIVE_KEY] = request.model_dump(mode="python") + handle = await executor.attach(launch_context, handle) + else: + handle.native_ref[RESUME_START_REQUEST_NATIVE_KEY] = request.model_dump(mode="python") + + target = _resume_target(resume_input) + instruction = str(resume_input.get("resume_instruction") or "").strip() + payload = ( + ResumePayload(kind="free_text", data=instruction) + if resume_input.get("resume_instruction_enabled") and instruction + else None + ) + return await executor.resume(handle, target, payload) + + +def _resume_target(resume_input: Mapping[str, Any]) -> ResumeTarget: + framework = str(resume_input.get("framework") or "").strip().lower() + framework_ref = resume_input.get("framework_ref") + raw_runtime_ref = framework_ref.get(framework) if isinstance(framework_ref, Mapping) else None + runtime_ref: Mapping[str, Any] = ( + raw_runtime_ref if isinstance(raw_runtime_ref, Mapping) else {} + ) + checkpoint_id = str(resume_input.get("checkpoint_id") or "").strip() + run_id = str(resume_input.get("run_id") or "").strip() + if framework == "langgraph": + return ResumeTarget(kind="checkpoint_id", id=checkpoint_id) + if framework == "codex": + thread_id = str(runtime_ref.get("thread_id") or runtime_ref.get("resume_thread_id") or "") + return ResumeTarget(kind="thread_id", id=thread_id or run_id) + invocation_id = str(runtime_ref.get("invocation_id") or run_id) + return ResumeTarget(kind="invocation_id", id=invocation_id) + + +def _persisted_resume_native_ref(resume_input: Mapping[str, Any]) -> dict[str, Any]: + framework = str(resume_input.get("framework") or "").strip().lower() + checkpoint_id = str(resume_input.get("checkpoint_id") or "").strip() + framework_ref = resume_input.get("framework_ref") + normalized_ref = dict(framework_ref) if isinstance(framework_ref, Mapping) else {} + runtime_ref = normalized_ref.get(framework) + native_ref = dict(runtime_ref) if isinstance(runtime_ref, Mapping) else {} + native_ref["framework_ref"] = normalized_ref + if checkpoint_id: + native_ref.setdefault("checkpoint_id", checkpoint_id) + native_ref.setdefault("known_checkpoint_ids", [checkpoint_id]) + return native_ref + + +def _validate_event_scope(event: RuntimeEvent, request: StartRequest) -> None: + expected = { + "agent_id": str(request.agent_id or ""), + "user_id": request.user_id, + "session_id": request.session_id, + "invocation_id": str(request.metadata["invocation_id"]), + } + mismatches = { + field: (expected_value, getattr(event, field)) + for field, expected_value in expected.items() + if getattr(event, field) != expected_value + } + if mismatches: + raise ValueError(f"runtime event scope does not match request: {mismatches!r}") + + +async def iter_runtime_conversation_semantic_events( + **kwargs: Any, +) -> AsyncIterator[dict[str, Any]]: + """Project canonical RuntimeEvents into the transport-neutral serializer input.""" + + accumulated_text = "" + usage: dict[str, Any] = {} + approval: dict[str, Any] | None = None + async for event in iter_runtime_conversation_events(**kwargs): + payload = event.payload + event_type = event.event_type + if event_type == EventType.RUN_STARTED: + yield { + "type": "started", + "session_id": event.session_id, + "metadata": dict(payload.get("metadata") or {}), + } + elif event_type in { + EventType.CONTEXT_COMPACTION_STARTED, + EventType.CONTEXT_COMPACTION_COMPLETED, + }: + yield { + "type": "compaction", + **dict(payload), + } + elif event_type == EventType.TEXT_DELTA: + delta = str(payload.get("text") or "") + replace = bool(payload.get("replace")) + accumulated_text = delta if replace else accumulated_text + delta + semantic: dict[str, Any] = {"type": "text", "delta": delta} + if replace: + semantic["replace"] = True + yield semantic + elif event_type == EventType.TEXT_COMPLETED: + snapshot = str(payload.get("text") or "") + if snapshot != accumulated_text: + if snapshot.startswith(accumulated_text): + delta = snapshot[len(accumulated_text) :] + if delta: + yield {"type": "text", "delta": delta} + else: + yield {"type": "text", "delta": snapshot, "replace": True} + accumulated_text = snapshot + elif event_type == EventType.REASONING_DELTA: + yield {"type": "thinking", "delta": str(payload.get("text") or "")} + elif event_type == EventType.TOOL_CALL_BEGIN: + yield { + "type": "tool_call", + "name": payload.get("name"), + "args": dict(payload.get("args") or {}), + "run_id": payload.get("call_id"), + "stage": payload.get("stage"), + "event_kind": payload.get("event_kind"), + "display_title": payload.get("display_title"), + "display_summary": payload.get("display_summary"), + } + elif event_type == EventType.TOOL_CALL_END: + yield { + "type": "tool_result", + "name": payload.get("name"), + "output": payload.get("result", payload.get("error", "")), + "run_id": payload.get("call_id"), + } + elif event_type == EventType.APPROVAL_REQUESTED: + detail = payload.get("detail") + approval = dict(detail) if isinstance(detail, Mapping) else {} + approval.setdefault("approval_request_id", payload.get("approval_id")) + approval.setdefault("id", payload.get("approval_id")) + approval.setdefault("call_id", payload.get("call_id")) + # ``kind=tool`` only describes the interrupt category. It is not + # a concrete tool name: inventing one would serialize a generic + # interrupt as an MCP approval request and lose the extension + # event that clients use to render a human-input prompt. + if payload.get("tool_name"): + approval.setdefault("tool_name", payload.get("tool_name")) + elif event_type == EventType.USAGE_REPORTED: + usage = dict(payload) + elif event_type == EventType.RUN_INTERRUPTED: + yield { + "type": "interrupt", + "interrupt_info": approval or dict(payload), + "session_id": event.session_id, + } + elif event_type == EventType.RUN_FAILED: + yield { + "type": "error", + "message": str(payload.get("error") or "Agent 运行失败"), + "session_id": event.session_id, + "usage": usage, + } + elif event_type == EventType.RUN_CANCELED: + yield { + "type": "cancelled", + "session_id": event.session_id, + "usage": usage, + } + elif event_type == EventType.RUN_COMPLETED: + raw_completion_metadata = payload.get("metadata") + completion_metadata = ( + dict(raw_completion_metadata) + if isinstance(raw_completion_metadata, Mapping) + else {} + ) + request_metadata = kwargs.get("request_metadata") + requested_agentengine = ( + request_metadata.get("agentengine") + if isinstance(request_metadata, Mapping) + else None + ) + if isinstance(requested_agentengine, Mapping): + completion_metadata["agentengine"] = dict(requested_agentengine) + if isinstance(payload.get("agentengine"), Mapping): + completion_metadata["agentengine"] = dict(payload["agentengine"]) + completion_metadata["runtime"] = { + "duration_ms": payload.get("duration_ms"), + "runtime_type": kwargs["launch_context"].runtime_type, + } + yield { + "type": "completed", + "output_text": accumulated_text, + "session_id": event.session_id, + "usage": usage, + "metadata": completion_metadata, + } + + +async def invoke_runtime_conversation_once( + **kwargs: Any, +) -> tuple[str, dict[str, Any]]: + """Collect one RuntimeExecutor turn for non-streaming protocol responses.""" + + session_id = str(kwargs.get("session_id") or "") + output_text = "" + usage: dict[str, Any] = {} + metadata: dict[str, Any] = {} + completed = False + async for event in iter_runtime_conversation_semantic_events(**kwargs): + event_type = event.get("type") + if event_type == "started": + session_id = str(event.get("session_id") or session_id) + elif event_type == "text": + delta = str(event.get("delta") or "") + output_text = delta if event.get("replace") else output_text + delta + elif event_type == "completed": + completed = True + session_id = str(event.get("session_id") or session_id) + output_text = str(event.get("output_text") or output_text) + usage = dict(event.get("usage") or {}) + metadata = dict(event.get("metadata") or {}) + elif event_type == "error": + raise RuntimeError(str(event.get("message") or "Agent 运行失败")) + elif event_type == "cancelled": + raise asyncio.CancelledError + elif event_type == "interrupt": + raise RuntimeError("runtime input is required before this run can complete") + if not completed: + raise RuntimeError("runtime did not produce a completed result") + return session_id, { + "output_text": output_text, + "usage": usage, + "metadata": metadata, + } + + +__all__ = [ + "invoke_runtime_conversation_once", + "iter_runtime_conversation_events", + "iter_runtime_conversation_semantic_events", +] diff --git a/ksadk/runtime/executor.py b/ksadk/runtime/executor.py new file mode 100644 index 00000000..810a7848 --- /dev/null +++ b/ksadk/runtime/executor.py @@ -0,0 +1,251 @@ +"""RuntimeAdapter 的统一生命周期路由与 Handle 所有权。""" + +from __future__ import annotations + +from contextlib import suppress +from dataclasses import dataclass + +from ksadk.runtime.adapter import ( + CancelResult, + CheckpointDescriptor, + PauseResult, + ResumePayload, + ResumeTarget, + RunHandle, + RuntimeAdapter, + RuntimeRegistry, + StartRequest, +) +from ksadk.runtime.launch import RuntimeLaunchContext + +_HandleKey = tuple[str, str, str] + + +@dataclass +class _OwnedRun: + adapter: RuntimeAdapter + handle: RunHandle + + +@dataclass +class RuntimeStartPreparation: + """One adapter instance preflighted for exactly one subsequent start.""" + + context: RuntimeLaunchContext + adapter: RuntimeAdapter + consumed: bool = False + + +class RuntimeExecutor: + """让每个 Handle 始终回到创建或恢复它的 Adapter 实例。""" + + def __init__(self, registry: RuntimeRegistry) -> None: + self._registry = registry + self._runs: dict[_HandleKey, _OwnedRun] = {} + + async def prepare_start(self, context: RuntimeLaunchContext) -> RuntimeStartPreparation: + """Preflight a fresh adapter and retain it for the matching ``start``. + + A response streaming endpoint must learn about lazy import/configuration + failures before its SSE headers are committed. Retaining the instance + matters: creating a second adapter after preflight could execute user + agent initialization twice. + """ + + adapter = self._registry.create(context) + await adapter.preflight() + return RuntimeStartPreparation(context=context, adapter=adapter) + + async def start( + self, + context: RuntimeLaunchContext, + request: StartRequest, + *, + preparation: RuntimeStartPreparation | None = None, + ) -> RunHandle: + adapter = self._take_prepared_adapter(context, preparation) + handle = await adapter.start(request) + expected_type = _normalize_runtime_type(context.runtime_type) + if _normalize_runtime_type(handle.runtime_type) != expected_type: + with suppress(Exception): + await adapter.close(handle) + raise ValueError( + "adapter returned a handle with the wrong runtime type: " + f"expected {expected_type!r}, got {handle.runtime_type!r}" + ) + if handle.session_id != request.session_id: + with suppress(Exception): + await adapter.close(handle) + raise ValueError( + "adapter returned a handle for the wrong session: " + f"expected {request.session_id!r}, got {handle.session_id!r}" + ) + if _handle_key(handle) in self._runs: + with suppress(Exception): + await adapter.close(handle) + raise RuntimeError(f"runtime handle is already attached: {_handle_key(handle)!r}") + self._record_owner(adapter, handle) + return handle + + def stream(self, handle: RunHandle): + owned = self._resolve(handle) + return owned.adapter.stream(owned.handle) + + async def cancel(self, handle: RunHandle) -> CancelResult: + owned = self._resolve(handle) + return await owned.adapter.cancel(owned.handle) + + async def pause(self, handle: RunHandle) -> PauseResult: + owned = self._resolve(handle) + return await owned.adapter.pause(owned.handle) + + async def submit(self, handle: RunHandle, payload: ResumePayload) -> None: + owned = self._resolve(handle) + await owned.adapter.submit(owned.handle, payload) + + async def resume( + self, + handle: RunHandle, + target: ResumeTarget, + payload: ResumePayload | None, + ) -> RunHandle: + old_key = _handle_key(handle) + owned = self._resolve(handle) + resumed = await owned.adapter.resume(owned.handle, target, payload) + if _normalize_runtime_type(resumed.runtime_type) != old_key[0]: + raise ValueError("resumed handle changed runtime type") + if resumed.session_id != old_key[2]: + raise ValueError("resumed handle changed session") + + new_key = _handle_key(resumed) + if new_key != old_key: + existing = self._runs.get(new_key) + if existing is not None and existing.adapter is not owned.adapter: + raise RuntimeError(f"runtime handle is already attached: {new_key!r}") + self._runs.pop(old_key, None) + self._runs[new_key] = _OwnedRun(adapter=owned.adapter, handle=resumed) + return resumed + + async def checkpoint(self, handle: RunHandle) -> CheckpointDescriptor: + owned = self._resolve(handle) + return await owned.adapter.checkpoint(owned.handle) + + async def close(self, handle: RunHandle) -> None: + key = _handle_key(handle) + owned = self._resolve(handle) + try: + await owned.adapter.close(owned.handle) + finally: + self._runs.pop(key, None) + + async def close_all(self) -> None: + """Release every attached adapter while preserving best-effort cleanup.""" + + first_error: BaseException | None = None + for key, owned in list(self._runs.items()): + try: + await owned.adapter.close(owned.handle) + except BaseException as exc: # cleanup must continue for the other runtimes + if first_error is None: + first_error = exc + finally: + self._runs.pop(key, None) + if first_error is not None: + raise first_error + + async def attach( + self, + context: RuntimeLaunchContext, + handle: RunHandle, + ) -> RunHandle: + expected_type = _normalize_runtime_type(context.runtime_type) + if _normalize_runtime_type(handle.runtime_type) != expected_type: + raise ValueError("launch context and persisted handle runtime type differ") + if self.is_attached(handle): + return handle + + adapter = self._registry.create(context) + restored = await adapter.attach(handle) + if restored != handle: + with suppress(Exception): + await adapter.close(restored) + raise ValueError("adapter attach must preserve persisted handle identity") + self._record_owner(adapter, restored) + return restored + + def is_attached(self, handle: RunHandle) -> bool: + owned = self._runs.get(_handle_key(handle)) + return owned is not None and owned.handle == handle + + def find_handle( + self, + runtime_type: str, + run_id: str, + session_id: str, + ) -> RunHandle | None: + """Return the attached handle for one exact runtime/run/session scope.""" + + owned = self._runs.get( + ( + _normalize_runtime_type(runtime_type), + str(run_id), + str(session_id), + ) + ) + return owned.handle if owned is not None else None + + def native_capabilities(self, context: RuntimeLaunchContext) -> dict[str, object]: + """Read the capability declaration from the registered Runtime implementation.""" + + adapter = self._registry.create(context) + return dict(adapter.runtime.native_capabilities()) + + def registered_runtime_types(self) -> list[str]: + """Expose Registry membership without leaking or duplicating the Registry.""" + + return self._registry.registered_types() + + def _resolve(self, handle: RunHandle) -> _OwnedRun: + key = _handle_key(handle) + try: + owned = self._runs[key] + except KeyError: + raise KeyError(f"runtime handle is not attached: {key!r}") from None + if owned.handle != handle: + raise ValueError(f"runtime handle payload does not match owner: {key!r}") + return owned + + def _record_owner(self, adapter: RuntimeAdapter, handle: RunHandle) -> None: + key = _handle_key(handle) + if key in self._runs: + raise RuntimeError(f"runtime handle is already attached: {key!r}") + self._runs[key] = _OwnedRun(adapter=adapter, handle=handle) + + def _take_prepared_adapter( + self, + context: RuntimeLaunchContext, + preparation: RuntimeStartPreparation | None, + ) -> RuntimeAdapter: + if preparation is None: + return self._registry.create(context) + if preparation.consumed: + raise RuntimeError("runtime start preparation was already consumed") + if preparation.context != context: + raise ValueError("runtime start preparation does not match launch context") + preparation.consumed = True + return preparation.adapter + + +def _normalize_runtime_type(runtime_type: str) -> str: + return runtime_type.strip().lower() + + +def _handle_key(handle: RunHandle) -> _HandleKey: + return ( + _normalize_runtime_type(handle.runtime_type), + handle.run_id, + handle.session_id, + ) + + +__all__ = ["RuntimeExecutor", "RuntimeStartPreparation"] diff --git a/ksadk/runtime/factory.py b/ksadk/runtime/factory.py new file mode 100644 index 00000000..decf1ac1 --- /dev/null +++ b/ksadk/runtime/factory.py @@ -0,0 +1,156 @@ +"""内置 RuntimeAdapter 的唯一默认 Factory Registry。""" + +from __future__ import annotations + +import dataclasses +import os +from pathlib import Path +from typing import Any + +from ksadk.codex.client import AsyncCodexClient +from ksadk.codex.runtime import CodexRuntimeAdapter +from ksadk.runners.base_runner import BaseRunner +from ksadk.runtime.adapter import RuntimeAdapter, RuntimeRegistry +from ksadk.runtime.framework_adapters import ADKRuntimeAdapter, LangGraphRuntimeAdapter +from ksadk.runtime.launch import RuntimeLaunchContext + + +def _create_codex(context: RuntimeLaunchContext) -> RuntimeAdapter: + client_factory = context.services.codex_client_factory or AsyncCodexClient + overrides = list(context.config.get("codex_overrides") or []) + # Studio's default collaboration mode must expose Codex's structured + # request_user_input tool; the upstream feature is intentionally off by + # default outside Plan mode. This stays process-local to the isolated + # runtime and does not mutate the user's global Codex configuration. + interaction_override = "features.default_mode_request_user_input=true" + if interaction_override not in overrides: + overrides.append(interaction_override) + try: + from openai_codex import CodexConfig + + base_cfg = CodexConfig() + if overrides: + base_cfg = dataclasses.replace( + base_cfg, + config_overrides=tuple(getattr(base_cfg, "config_overrides", ()) or ()) + + tuple(str(o) for o in overrides), + ) + env = dict(getattr(base_cfg, "env", None) or {}) + isolated_home = _isolated_codex_home(context.project_dir) + env.setdefault("CODEX_HOME", str(isolated_home)) + # HOME 级隔离:codex app-server 还会按约定扫 ~/.agents/skills、 + # ~/.claude/skills 等宿主目录,仅设 CODEX_HOME 挡不住。隔离 HOME 后这些 + # 路径全部落在工作区内,宿主 skills/MCP/凭证不再进入 Agent 上下文。 + # 设 KSADK_CODEX_ISOLATE_HOME=0 可关闭(调试用)。 + if os.environ.get("KSADK_CODEX_ISOLATE_HOME", "1") != "0": + env.setdefault("HOME", str(isolated_home)) + # 运行期解析出的凭证值(如 MCP bearer_token_env_var 所需)注入子进程环境 + env.update({str(k): str(v) for k, v in (context.config.get("env") or {}).items()}) + base_cfg = dataclasses.replace(base_cfg, env=env) + try: + client: Any = client_factory(config=base_cfg) + except TypeError: + # 自定义/测试工厂可能不接受 config 参数:退化为无参构造, + # config_overrides 事后注入(自定义工厂自行承担 env 隔离)。 + client = client_factory() + if overrides: + _apply_codex_overrides(client, overrides) + except ImportError: + client: Any = client_factory() + if overrides: + _apply_codex_overrides(client, overrides) + timeout = context.config.get("turn_timeout_seconds") + return CodexRuntimeAdapter( + client, + sandbox_read_only=bool(context.config.get("sandbox_read_only", True)), + turn_timeout_seconds=float(timeout) if timeout is not None else None, + ) + + +def _isolated_codex_home(project_dir: Any) -> Path: + """Studio 运行 Agent 使用工作区级隔离 CODEX_HOME。 + + 避免 codex app-server 继承宿主环境(~/.codex / ~/.wework/codex)里的 + skills、MCP 配置、插件与凭证,污染 Agent 上下文甚至泄漏宿主能力。 + 可用 KSADK_CODEX_HOME 显式覆盖。 + """ + override = os.environ.get("KSADK_CODEX_HOME") + if override: + return Path(override).expanduser() + home = Path(str(project_dir)) / ".agentkit" / "codex-home" + home.mkdir(parents=True, exist_ok=True) + return home + + +def _apply_codex_overrides(client: Any, overrides: Any) -> None: + """Append MCP server --config overrides to the client's CodexConfig.""" + try: + from openai_codex import CodexConfig + except ImportError: + return + # ksadk AsyncCodexClient -> AsyncCodex -> inner AsyncCodexClient.config + inner_codex = getattr(client, "_codex", None) + inner_client = getattr(inner_codex, "_client", None) or inner_codex + cfg = getattr(inner_client, "config", None) + if cfg is None or not isinstance(cfg, CodexConfig): + return + base_overrides = tuple(getattr(cfg, "config_overrides", ()) or ()) + new_overrides = tuple(str(o) for o in overrides if o not in base_overrides) + if not new_overrides: + return + new_cfg = dataclasses.replace(cfg, config_overrides=base_overrides + new_overrides) + setattr(inner_client, "config", new_cfg) + + +def _create_framework_runner(context: RuntimeLaunchContext, runtime_type: str) -> BaseRunner: + try: + from ksadk.runners.patch_langchain import apply_patch + + apply_patch() + except ImportError: + pass + detection = context.detection + if detection is None: + raise ValueError(f"{runtime_type} runtime requires framework detection") + if context.services.runner_factory is not None: + runner = context.services.runner_factory(detection, str(context.project_dir)) + else: + # 复用 runners.factory.create_runner:它优先读 detection.runner_class + # (agentengine.yaml 的 runner_class 字段),否则按 detection.type 分发到 + # ADKRunner/LangGraphRunner 等。直接硬编码 LangGraphRunner 会绕过自定义 + # runner,导致 root_agent=None 的项目在 preflight 阶段误报"不是有效 CompiledGraph"。 + from ksadk.runners.factory import create_runner + + runner = create_runner(detection, str(context.project_dir)) + if not isinstance(runner, BaseRunner): + raise TypeError( + f"{runtime_type} runner factory must return BaseRunner, got {type(runner).__name__}" + ) + return runner + + +def _create_adk(context: RuntimeLaunchContext) -> RuntimeAdapter: + return ADKRuntimeAdapter(_create_framework_runner(context, "adk")) + + +def _create_langgraph(context: RuntimeLaunchContext) -> RuntimeAdapter: + return LangGraphRuntimeAdapter(_create_framework_runner(context, "langgraph")) + + +def build_default_runtime_registry() -> RuntimeRegistry: + """注册内置 Codex、ADK 和 LangGraph Runtime Factory。""" + + registry = RuntimeRegistry() + registry.register("codex", _create_codex) + registry.register("adk", _create_adk) + registry.register("langgraph", _create_langgraph) + return registry + + +def create_runtime_adapter(context: RuntimeLaunchContext) -> RuntimeAdapter: + """通过唯一默认 Registry 创建 RuntimeAdapter。""" + + return build_default_runtime_registry().create(context) + + +__all__ = ["build_default_runtime_registry", "create_runtime_adapter"] diff --git a/ksadk/runtime/framework_adapters.py b/ksadk/runtime/framework_adapters.py index e31b99f5..f6775b2c 100644 --- a/ksadk/runtime/framework_adapters.py +++ b/ksadk/runtime/framework_adapters.py @@ -6,7 +6,7 @@ - ADK:**forward-only**,resume 经 ``invocation_id``;不支持 time-travel/fork。 - LangGraph:**time-travel**,resume 经 ``checkpoint_id``;可按 turn 回滚/fork。 -并用 :func:`build_default_registry` 注册进 G0.3 ``RuntimeRegistry``。 +默认注册由 :mod:`ksadk.runtime.factory` 统一负责。 """ from __future__ import annotations @@ -19,7 +19,6 @@ ResumePayload, ResumeTarget, RunHandle, - RuntimeRegistry, ) from ksadk.runtime.runner_adapter import RunnerRuntimeAdapter @@ -88,10 +87,14 @@ async def _resume_native( # ``checkpoint_resume`` + ``framework_ref.langgraph.{checkpoint_id,thread_id}``。 return { "checkpoint_resume": True, + "run_id": handle.run_id, "resume_payload_provided": payload is not None, "resume_interrupt_id": payload.call_id if payload else None, "framework_ref": { - "langgraph": {"checkpoint_id": target.id, "thread_id": handle.session_id} + "langgraph": { + "checkpoint_id": target.id, + "thread_id": str(handle.native_ref.get("thread_id") or handle.session_id), + } }, "input": payload.data if payload else None, } @@ -109,16 +112,7 @@ def _checkpoint_capability(self) -> CheckpointCapability: ) -def build_default_registry() -> RuntimeRegistry: - """构造默认 RuntimeRegistry 并注册 ADK/LangGraph adapter 类型。""" - registry = RuntimeRegistry() - registry.register("adk", ADKRuntimeAdapter) - registry.register("langgraph", LangGraphRuntimeAdapter) - return registry - - __all__ = [ "ADKRuntimeAdapter", "LangGraphRuntimeAdapter", - "build_default_registry", ] diff --git a/ksadk/runtime/launch.py b/ksadk/runtime/launch.py new file mode 100644 index 00000000..6ede7d0f --- /dev/null +++ b/ksadk/runtime/launch.py @@ -0,0 +1,48 @@ +"""RuntimeAdapter Factory 的启动上下文与可注入依赖。""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Any + +_RUNTIME_TYPE_ALIASES = { + # LangChain and DeepAgents are source-framework variants. They share the + # LangGraph execution/checkpoint contract and must not create new top-level + # runtime types in the RuntimeAdapter registry. + "langchain": "langgraph", + "deepagents": "langgraph", +} + + +@dataclass(frozen=True) +class RuntimeServices: + """创建 RuntimeAdapter 时可替换的进程级依赖。""" + + codex_client_factory: Callable[[], Any] | None = None + runner_factory: Callable[[Any, str], Any] | None = None + + +@dataclass(frozen=True) +class RuntimeLaunchContext: + """一次 RuntimeAdapter 创建所需的框架无关输入。""" + + runtime_type: str + project_dir: Path + detection: Any | None = None + config: Mapping[str, Any] = field(default_factory=dict) + services: RuntimeServices = field(default_factory=RuntimeServices) + + def __post_init__(self) -> None: + runtime_type = str(self.runtime_type or "").strip().lower() + canonical_runtime_type = _RUNTIME_TYPE_ALIASES.get( + runtime_type, runtime_type + ) + object.__setattr__(self, "runtime_type", canonical_runtime_type) + object.__setattr__(self, "project_dir", Path(self.project_dir)) + object.__setattr__(self, "config", MappingProxyType(dict(self.config))) + + +__all__ = ["RuntimeLaunchContext", "RuntimeServices"] diff --git a/ksadk/runtime/preprocessing.py b/ksadk/runtime/preprocessing.py index 18aae7c6..a4fc75e1 100644 --- a/ksadk/runtime/preprocessing.py +++ b/ksadk/runtime/preprocessing.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from typing import Any @@ -11,6 +12,7 @@ _inject_runner_deferred_tools_for_request, _runner_type_name, ) +from ksadk.conversations.runtime_payloads import PreparedConversationTurn from ksadk.conversations.runtime_preparation import build_run_input from ksadk.runtime.adapter import ( CONVERSATION_PREPROCESSING_METADATA_KEY, @@ -60,20 +62,28 @@ async def prepare_runtime_start(request: StartRequest, runner: Any) -> PreparedR } request_metadata = {**outer_metadata, **conversation.request_metadata} messages = conversation.messages or _fallback_messages(request.input) - prepared = await build_run_input( - agent_id=str(request.agent_id or "agent"), - user_id=request.user_id, - session_id=request.session_id, - messages=messages, - model=request.model, - model_metadata=conversation.model_metadata or None, - model_options=conversation.model_options or None, - state_delta=conversation.state_delta or None, - instructions=conversation.instructions, - request_metadata=request_metadata, - custom_metadata=conversation.custom_metadata, - invocation_id=str(request.metadata.get("invocation_id") or "") or None, - ) + raw_prepared = (conversation.model_extra or {}).get("prepared_turn") + if isinstance(raw_prepared, Mapping): + prepared = PreparedConversationTurn(**dict(raw_prepared)) + prepared.request_metadata = { + **prepared.request_metadata, + **request_metadata, + } + else: + prepared = await build_run_input( + agent_id=str(request.agent_id or "agent"), + user_id=request.user_id, + session_id=request.session_id, + messages=messages, + model=request.model, + model_metadata=conversation.model_metadata or None, + model_options=conversation.model_options or None, + state_delta=conversation.state_delta or None, + instructions=conversation.instructions, + request_metadata=request_metadata, + custom_metadata=conversation.custom_metadata, + invocation_id=str(request.metadata.get("invocation_id") or "") or None, + ) _inject_runner_deferred_tools_for_request(runner, prepared) ambient_contexts = _build_runner_ambient_contexts( runner=runner, @@ -100,6 +110,9 @@ async def prepare_runtime_start(request: StartRequest, runner: Any) -> PreparedR model_options=prepared.model_options, kb_context=ambient_contexts.get("kb_context"), memory_context=ambient_contexts.get("memory_context"), + tool_approval_mode=str( + prepared.request_metadata.get("tool_approval_mode") or "" + ), ) canonical_payload = _build_runner_request_payload( prepared=prepared, diff --git a/ksadk/runtime/runner_adapter.py b/ksadk/runtime/runner_adapter.py index cc7eaab0..10bd9c98 100644 --- a/ksadk/runtime/runner_adapter.py +++ b/ksadk/runtime/runner_adapter.py @@ -31,6 +31,7 @@ from ksadk.events.runtime_event import EventType, RuntimeEvent from ksadk.runners.base_runner import BaseRunner from ksadk.runtime.adapter import ( + RESUME_START_REQUEST_NATIVE_KEY, BaseRuntime, CancelResult, CheckpointCapability, @@ -42,7 +43,9 @@ StartRequest, ) from ksadk.runtime.preprocessing import PreparedRuntimeStart, prepare_runtime_start +from ksadk.runtime.runner_loading import ensure_runner_loaded from ksadk.runtime_context import platform_invocation_scope +from ksadk.tools.gateway import approval_interrupt_info_from_result logger = logging.getLogger(__name__) @@ -157,6 +160,7 @@ class _ActiveRun: resume_fingerprint: Any = None skip_runner: bool = False done: bool = False + completion_metrics: dict[str, Any] = field(default_factory=dict) class RunnerRuntimeAdapter(RuntimeAdapter): @@ -230,7 +234,13 @@ async def _resume_native( # ---- 六动词 ---- + async def preflight(self) -> None: + """Load the framework agent before a streaming HTTP response commits.""" + + ensure_runner_loaded(self._runner, runtime_type=self._runtime_type) + async def start(self, request: StartRequest) -> RunHandle: + ensure_runner_loaded(self._runner, runtime_type=self._runtime_type) run_id = str(request.metadata.get("invocation_id") or self._next_invocation_id()) prepared_start = await prepare_runtime_start(request, self._runner) handle = RunHandle( @@ -336,6 +346,12 @@ async def resume( return handle self._require_native_checkpoint_capability() self._known_runs.add(handle.run_id) + prepared_start: PreparedRuntimeStart | None = None + raw_start_request = handle.native_ref.pop(RESUME_START_REQUEST_NATIVE_KEY, None) + if isinstance(raw_start_request, Mapping): + prepared_start = await prepare_runtime_start( + StartRequest.model_validate(raw_start_request), self._runner + ) # _resume_native 返回的 runner_input 覆盖存到 run 上,下一次 stream() 经 # _build_runner_input 消费,以框架原生方式真驱动恢复。 override = await self._resume_native(handle, target, payload) @@ -380,6 +396,8 @@ async def resume( resume_key=resume_key, resume_fingerprint=resume_fingerprint, ) + if prepared_start is not None: + run.__dict__["_prepared_start"] = prepared_start if override: run.__dict__["_resume_input_override"] = override self._resume_decisions[resume_key] = resume_fingerprint @@ -493,7 +511,7 @@ async def _stream_events(self, handle: RunHandle) -> AsyncIterator[RuntimeEvent] yield self._event( handle, EventType.RUN_COMPLETED, - {"status": "already_resumed"}, + self._completion_payload(handle, status="already_resumed"), ) return @@ -540,7 +558,15 @@ async def _stream_events(self, handle: RunHandle) -> AsyncIterator[RuntimeEvent] {"status": "input_required"}, ) elif not terminal_event_seen: - yield self._event(handle, EventType.RUN_COMPLETED, {"status": "completed"}) + yield self._event( + handle, + EventType.RUN_COMPLETED, + self._completion_payload( + handle, + status="completed", + metrics=run.completion_metrics, + ), + ) finally: run.stream = None run.done = True @@ -551,19 +577,28 @@ def _build_runner_input(self, handle: RunHandle, request: Optional[StartRequest] # 直接作为 runner 输入,驱动框架原生恢复。 run = self._active_runs.get(handle.run_id) override = run.__dict__.get("_resume_input_override") if run is not None else None + prepared_start = run.__dict__.get("_prepared_start") if run is not None else None if override is not None: - merged = { + merged = ( + dict(prepared_start.runner_input) + if isinstance(prepared_start, PreparedRuntimeStart) + else {} + ) + base_metadata = merged.get("metadata") + merged.update({ "input": override.get("input"), "session_id": handle.session_id, "invocation_id": handle.run_id, - "metadata": dict(override.get("metadata") or {}), - } + "metadata": { + **(dict(base_metadata) if isinstance(base_metadata, Mapping) else {}), + **dict(override.get("metadata") or {}), + }, + }) for key, value in override.items(): if key not in ("input", "metadata"): merged[key] = value return merged - prepared_start = run.__dict__.get("_prepared_start") if run is not None else None if isinstance(prepared_start, PreparedRuntimeStart): return dict(prepared_start.runner_input) @@ -615,7 +650,12 @@ async def _map_runner_stream( _set_conversation_input_attributes(span, prepared_start.input_text) try: with scope: - stream_result = self._runner.stream(runner_input) + canonical_stream = getattr(self._runner, "stream_runtime_events", None) + stream_result = ( + canonical_stream(runner_input) + if callable(canonical_stream) + else self._runner.stream(runner_input) + ) if inspect.iscoroutine(stream_result): # runner.stream 若声明为 async def -> AsyncIterator(非 async generator), # 调用返回 coroutine,需 await 得到迭代器。 @@ -657,8 +697,29 @@ async def _map_runner_stream( run.chunk_task = None if chunk is _STREAM_STOP: return + if isinstance(chunk, RuntimeEvent): + if chunk.event_type in { + EventType.TEXT_DELTA, + EventType.TEXT_COMPLETED, + } and chunk.phase == "final_answer": + text = self._coerce(chunk.payload.get("text")) + if chunk.event_type == EventType.TEXT_COMPLETED: + accumulated_output = text + else: + accumulated_output += text + elif chunk.event_type == EventType.USAGE_REPORTED: + usage.update(chunk.payload) if isinstance(chunk, dict): chunk_type = str(chunk.get("type") or "") + if chunk_type == "final" and run is not None: + for source_key, target_key in ( + ("duration_ms", "duration_ms"), + ("started_at", "started_at"), + ("completed_at", "completed_at"), + ("metrics_source", "source"), + ): + if chunk.get(source_key) is not None: + run.completion_metrics[target_key] = chunk[source_key] if chunk_type in {"final", "text", "text_delta"}: text = self._coerce( chunk.get("delta") or chunk.get("output") or chunk.get("data") @@ -695,6 +756,25 @@ async def _map_runner_stream( def _chunk_to_event( self, handle: RunHandle, run: Optional[_ActiveRun], chunk: Any ) -> Optional[RuntimeEvent]: + if isinstance(chunk, RuntimeEvent): + # Outer adapter owns the public lifecycle envelope. A native + # Runtime may emit its own RUN_STARTED with private identifiers; + # suppress that duplicate and rebind all other canonical events + # to the public handle without flattening their payloads. + if chunk.event_type == EventType.RUN_STARTED: + return None + return RuntimeEvent.create( + chunk.event_type, + agent_id=str(handle.native_ref.get("agent_id") or "agent"), + user_id=str(handle.native_ref.get("user_id") or "user"), + session_id=handle.session_id, + invocation_id=handle.run_id, + seq_id=self._next_seq(), + phase=chunk.phase, + payload=dict(chunk.payload), + event_id=chunk.event_id, + timestamp=chunk.timestamp, + ) if not isinstance(chunk, dict): return self._event( handle, EventType.TEXT_DELTA, {"text": str(chunk)}, phase="commentary" @@ -747,50 +827,57 @@ def _chunk_to_event( or "" ) name = str(chunk.get("tool_name") or chunk.get("name") or "tool") + tool_args = chunk.get("tool_args", chunk.get("args")) + result = chunk.get("tool_output", chunk.get("output")) + approval_detail = approval_interrupt_info_from_result( + result, + fallback_tool_name=name, + tool_args=tool_args, + run_id=call_id or None, + ) + if approval_detail is not None: + return self._approval_requested_event( + handle, + run, + detail=approval_detail, + call_id=call_id, + ) return self._event( handle, EventType.TOOL_CALL_END, { "call_id": call_id or name, "name": name, - "result": chunk.get("tool_output", chunk.get("output")), + "result": result, "error": chunk.get("error"), }, ) if chunk_type in ("interrupt", "approval", "approval_required"): - detail = chunk.get("interrupt_info") or chunk.get("detail") or {} - detail_id = detail.get("approval_request_id") if isinstance(detail, dict) else None + raw_detail = chunk.get("interrupt_info") or chunk.get("detail") or {} + detail = dict(raw_detail) if isinstance(raw_detail, Mapping) else {} call_id = str( chunk.get("call_id") or chunk.get("approval_id") or chunk.get("id") - or detail_id or "" ) - if run is not None and call_id: - run.pending_approvals.add(call_id) - if call_id: - pending_approval_ids = handle.native_ref.setdefault("pending_approval_ids", []) - if call_id not in pending_approval_ids: - pending_approval_ids.append(call_id) - return self._event( + return self._approval_requested_event( handle, - EventType.APPROVAL_REQUESTED, - { - "approval_id": call_id, - "call_id": call_id, - "kind": "tool", - "detail": detail, - }, + run, + detail=detail, + call_id=call_id, ) if chunk_type == "checkpoint": - metadata = chunk.get("metadata") or {} - agentengine = metadata.get("agentengine") if isinstance(metadata, dict) else {} - framework_ref = ( - agentengine.get("framework_ref") if isinstance(agentengine, dict) else {} - ) or {} + raw_metadata = chunk.get("metadata") + metadata: dict[str, Any] = raw_metadata if isinstance(raw_metadata, dict) else {} + raw_agentengine = metadata.get("agentengine") + agentengine: dict[str, Any] = ( + raw_agentengine if isinstance(raw_agentengine, dict) else {} + ) + framework = str(agentengine.get("framework") or self._runtime_type) + framework_ref = agentengine.get("framework_ref") or {} runtime_ref = ( - framework_ref.get(self._runtime_type) if isinstance(framework_ref, dict) else {} + framework_ref.get(framework) if isinstance(framework_ref, dict) else {} ) or {} checkpoint_id = str( runtime_ref.get("checkpoint_id") if isinstance(runtime_ref, dict) else "" @@ -810,6 +897,9 @@ def _chunk_to_event( { "checkpoint_id": checkpoint_id, "granularity": self._checkpoint_capability().granularity, + "run_id": str(agentengine.get("run_id") or handle.run_id), + "framework": framework, + "framework_ref": framework_ref, "resume_target": framework_ref, }, ) @@ -823,6 +913,21 @@ def _chunk_to_event( "state_update": self._coerce(chunk.get("output")), }, ) + if chunk_type == "usage": + raw_usage = chunk.get("usage") + usage: dict[str, Any] = raw_usage if isinstance(raw_usage, dict) else {} + return self._event( + handle, + EventType.USAGE_REPORTED, + { + "input_tokens": int(usage.get("input_tokens") or 0), + "output_tokens": int(usage.get("output_tokens") or 0), + "total_tokens": int(usage.get("total_tokens") or 0), + "cached_tokens": int(usage.get("cached_tokens") or 0), + "reasoning_tokens": int(usage.get("reasoning_tokens") or 0), + "source": str(usage.get("source") or self._runtime_type), + }, + ) if chunk_type == "error": error = self._coerce(chunk.get("message") or chunk.get("error")) return self._event( @@ -848,6 +953,37 @@ def _chunk_to_event( payload["replace"] = True return self._event(handle, EventType.TEXT_DELTA, payload, phase="commentary") + def _approval_requested_event( + self, + handle: RunHandle, + run: Optional[_ActiveRun], + *, + detail: Mapping[str, Any], + call_id: str, + ) -> RuntimeEvent: + """Convert one framework/tool approval to the canonical runtime event.""" + + approval_id = str( + detail.get("approval_request_id") or detail.get("id") or call_id or "" + ) + resolved_call_id = str(call_id or approval_id) + if run is not None and approval_id: + run.pending_approvals.add(approval_id) + if approval_id: + pending_approval_ids = handle.native_ref.setdefault("pending_approval_ids", []) + if approval_id not in pending_approval_ids: + pending_approval_ids.append(approval_id) + return self._event( + handle, + EventType.APPROVAL_REQUESTED, + { + "approval_id": approval_id, + "call_id": resolved_call_id, + "kind": "tool", + "detail": dict(detail), + }, + ) + def _event( self, handle: RunHandle, @@ -867,6 +1003,23 @@ def _event( payload=payload, ) + def _completion_payload( + self, + handle: RunHandle, + *, + status: str, + metrics: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = {"status": status, **dict(metrics or {})} + framework_ref = handle.native_ref.get("framework_ref") + if isinstance(framework_ref, Mapping) and framework_ref: + payload["agentengine"] = { + "run_id": handle.run_id, + "framework": self._runtime_type, + "framework_ref": dict(framework_ref), + } + return payload + @staticmethod def _coerce(value: Any) -> str: if value is None: diff --git a/ksadk/runtime/runner_loading.py b/ksadk/runtime/runner_loading.py new file mode 100644 index 00000000..fb7f973f --- /dev/null +++ b/ksadk/runtime/runner_loading.py @@ -0,0 +1,36 @@ +"""Lazy framework-runner initialization at the RuntimeAdapter boundary.""" + +from __future__ import annotations + +from typing import Any + + +def ensure_runner_loaded(runner: Any, *, runtime_type: str) -> None: + """Load a runner at first real execution, never during app bootstrap. + + ``RuntimeRegistry.create()`` is also used by capability/bootstrap paths; + loading there would execute user code merely for rendering Studio. The + first ``start`` is the shared execution boundary for HTTP, A2A and Studio, + so loader errors are still returned before a stream body is opened. + """ + + if bool(getattr(runner, "_ksadk_runtime_loaded", False)): + return + # A caller may deliberately inject an already-constructed framework agent + # (for example a durable restore or an embedding application). Respect that + # ownership and do not re-import its entrypoint. + if getattr(runner, "_agent", None) is not None: + setattr(runner, "_ksadk_runtime_loaded", True) + return + load_agent = getattr(runner, "load_agent", None) + if not callable(load_agent): + # Runtime factories require BaseRunner, but lightweight adapters used by + # an external integration may already be executable and expose no + # separate initialization hook. There is nothing to defer in that case. + setattr(runner, "_ksadk_runtime_loaded", True) + return + load_agent() + setattr(runner, "_ksadk_runtime_loaded", True) + + +__all__ = ["ensure_runner_loaded"] diff --git a/ksadk/sandbox/backends/e2b.py b/ksadk/sandbox/backends/e2b.py index 965e1a4d..600d7370 100644 --- a/ksadk/sandbox/backends/e2b.py +++ b/ksadk/sandbox/backends/e2b.py @@ -126,7 +126,7 @@ def create_session( sandbox_cls = self.sandbox_cls if sandbox_cls is None: try: - from e2b import Sandbox # type: ignore[import-untyped] + from e2b import Sandbox # type: ignore[import-not-found, import-untyped] except ImportError as exc: raise SandboxError( "e2b>=2.15.3,<2.25.0 is required for KSADK_SANDBOX_BACKEND=e2b" diff --git a/ksadk/server/__init__.py b/ksadk/server/__init__.py index 45eaed8b..36e4e61b 100644 --- a/ksadk/server/__init__.py +++ b/ksadk/server/__init__.py @@ -1,7 +1,42 @@ +"""Lazy RuntimeAdapter-first server composition exports. + +Keeping package import side-effect free is required because conversation models +import ``ksadk.server.api_models`` during RuntimeAdapter initialization. """ -KsADK Server - FastAPI 服务模块 -""" -from ksadk.server.app import app, set_runner +from __future__ import annotations + +import importlib +from typing import Any + +__all__ = [ + "ALL_GROUPS", + "CONTROL_PLANE_GROUPS", + "DATA_PLANE_GROUPS", + "RuntimeAppConfig", + "RuntimeAppState", + "configure_runtime_app", + "create_runtime_app", +] + + +def __getattr__(name: str) -> Any: + if name == "routes": + value = importlib.import_module("ksadk.server.routes") + globals()[name] = value + return value + if name == "configure_runtime_app": + from ksadk.server.composition import configure_runtime_app -__all__ = ["app", "set_runner"] + return configure_runtime_app + if name in { + "ALL_GROUPS", + "CONTROL_PLANE_GROUPS", + "DATA_PLANE_GROUPS", + "RuntimeAppConfig", + "RuntimeAppState", + "create_runtime_app", + }: + factory = importlib.import_module("ksadk.server.factory") + return getattr(factory, name) + raise AttributeError(name) diff --git a/ksadk/server/app.py b/ksadk/server/app.py index 09f453d4..d3ded141 100644 --- a/ksadk/server/app.py +++ b/ksadk/server/app.py @@ -1,501 +1,27 @@ -# ksadk/server/app.py -"""Default runtime app facade and backwards-compatible server exports.""" +"""Explicit RuntimeAdapter-first FastAPI composition API. -from __future__ import annotations +There is deliberately no module-level ``app`` and no ``set_runner`` global. +Every caller must provide a RuntimeExecutor and RuntimeLaunchContext through +RuntimeAppConfig, so Web, protocol, harness, and generated entrypoints share the +same execution contract. +""" -import os -from collections.abc import AsyncIterator -from pathlib import Path -from typing import Any - -from fastapi import FastAPI -from fastapi.responses import StreamingResponse - -import ksadk.conversations as conversation from ksadk.server.composition import configure_runtime_app from ksadk.server.factory import ( + ALL_GROUPS, + CONTROL_PLANE_GROUPS, + DATA_PLANE_GROUPS, RuntimeAppConfig, RuntimeAppState, create_runtime_app, - get_state, - set_fallback_state, - shutdown_runtime_resources, -) -from ksadk.server.routes import dependencies as route_dependencies -from ksadk.server.routes.checkpoint_resolution import ( - _find_session_checkpoint, - _resolve_checkpoint_resume_input_from_session, -) -from ksadk.server.routes.common import ( - _CUSTOM_API_PROXY_ENV_KEYS, - _HOP_BY_HOP_HEADERS, - _MAX_INLINE_BASE64_CHARS, - _MAX_INLINE_TEXT_CHARS, - _MAX_REFERENCE_TEXT_BYTES, - _NATIVE_TUI_FRAMEWORKS, - _RESERVED_UI_PATHS, - _TEXT_FILE_EXTENSIONS, - _TEXT_MIME_PREFIXES, - _TEXT_MIME_TYPES, - _UPLOAD_URI_SCHEME, - STATIC_DIR, - _action_response, - _attachment_from_part, - _attachment_prompt_text, - _build_bootstrap_model_payload, - _build_native_terminal_capability, - _current_framework, - _custom_api_proxy_base_url, - _decode_inline_data, - _default_custom_ui_bundle_dir, - _ensure_runner_loaded, - _ensure_session, - _extract_inline_attachment_text, - _extract_pdf_text, - _extract_user_input_from_parts, - _hydrate_session, - _is_custom_ui_static_asset_path, - _is_textual_mime, - _looks_like_textual_attachment, - _normalize_request_ui_path, - _prepare_runner_for_model, - _proxy_headers, - _read_attachment_bytes, - _register_integrated_routers, - _request_id, - _resolve_active_runner, - _resolve_agent_ui_spec, - _resolve_attachment_storage_path, - _resolve_current_model, - _resolve_ui_static_response, - _resolve_uploads_dir, - _response_headers, - _runner_project_dir, - _sanitize_session_state_for_action, - _ui_state_with_env_fallback, - _workspace_root_dir, - _workspace_runtime_request, - custom_api_proxy, - health_check, - list_apps, - set_runner, -) -from ksadk.server.routes.control import ( - get_checkpoint_resume_preview_action, - resume_run_action, - subscribe_run_events_action, -) -from ksadk.server.routes.misc import ( - _feedback_payload_from_state, - _feedback_state_key, - _find_feedback_assistant_event, - create_session, - delete_response_feedback_action, - delete_session, - get_agent_builder, - get_event_graph, - get_event_trace, - get_response_feedback_action, - get_session, - get_session_trace, - get_traces, - list_agent_models_action, - list_eval_results, - list_eval_sets, - list_sessions, - save_agent_builder, - save_session_to_memory, - serve_agent_ui_static, - upsert_response_feedback_action, -) -from ksadk.server.routes.models import ( - _EVENT_SCAN_PAGE_SIZE, - _MAX_PREVIEW_TOOL_RECEIPTS, - _RUN_ACTIVE_STATUSES, - _RUN_TERMINAL_STATUSES, - CancelRunActionRequest, - CreateSessionActionRequest, - GetCheckpointResumePreviewActionRequest, - ListSessionCheckpointsActionRequest, - ListSessionEventsActionRequest, - ListSessionMessagesActionRequest, - ListSessionsActionRequest, - ListToolReceiptsActionRequest, - ResponseFeedbackRefActionRequest, - ResponsesRequest, - ResumeRunActionRequest, - RunAgentActionRequest, - SessionIdRequest, - UiBootstrapRequest, - UpsertResponseFeedbackActionRequest, - WorkspaceDeleteActionRequest, - WorkspaceListActionRequest, - _clean_optional_string, - _event_run_id, - _event_text, - _latest_session_run_metadata, - _latest_session_run_status, - _metadata_invocation_id, - _parse_iso_datetime, - _resolve_responses_conversation_id, - _resolve_responses_session_and_user, - _run_agent_response_metadata, - _run_status_payload_status, - _runtime_agent_id, - _session_topic_from_events, - _session_user_prompt_from_event, - _split_custom_metadata, - _truncate_session_text, -) -from ksadk.server.routes.openai_compat import ( - ChatCompletionRequest, - chat_completions, - list_openai_models, - responses, -) -from ksadk.server.routes.projection import ( - _SIDE_EFFECT_TOOL_NAMES, - _agent_contains_invocation, - _apply_adk_only_latest_resumable, - _apply_checkpoint_resume_audit, - _apply_latest_checkpoint_policy, - _build_checkpoint_resume_preview, - _check_adk_latest_resumable, - _checkpoint_event_to_action_payload, - _checkpoint_resume_disabled_detail, - _event_invocation_id, - _event_to_action_payload, - _extend_invocation_after_window, - _extend_invocation_before_window, - _iter_agent_event_pages, - _iter_scoped_event_pages, - _iter_session_event_pages, - _iter_with_idle_heartbeat, - _latest_invocation_status, - _oldest_unconsumed_session_events, - _record_resume_audit, - _require_action_session, - _resume_audit_by_checkpoint, - _session_contains_invocation, - _session_to_action_payload, - _tool_receipt_event_to_action_payload, -) -from ksadk.server.routes.routers import ( - GROUP_ROUTERS, - builder_router, - control_router, - debug_router, - feedback_router, - health_meta_router, - models_router, - openai_compat_router, - run_router, - sessions_adk_compat_router, - sessions_router, - tools_router, - ui_bootstrap_router, - workspace_router, -) -from ksadk.server.routes.run import cancel_agent_changes, run_agent_action, run_sse -from ksadk.server.routes.sessions import ( - _count_resumable_checkpoints, - _list_checkpoints_payload, - create_session_action, - delete_session_action, - get_agent_ui_bootstrap, - get_session_action, - list_session_checkpoints_action, - list_session_events_action, - list_session_messages_action, - list_sessions_action, - list_tool_receipts_action, -) -from ksadk.server.routes.streaming import ( - _cancel_detached_streams_for_session, - _clear_detached_resume_key, - _detached_resume_key_from_input, - _DetachedSSEStream, - _reject_if_detached_resume_active, -) -from ksadk.server.routes.workspace import ( - ListAgentModelsRequest, - _build_models_payload, - _normalize_model_catalog_items, - attachment_content_action, - cancel_run_action, - delete_workspace_file_action, - export_workspace_zip, - get_workspace_file_content_action, - list_workspace_files_action, - upload_file_action, - upload_workspace_file_action, - workspace_file_path_route, -) -from ksadk.sessions import describe_session_backend, resolve_session_service - -# These names were historically importable from this module. Keeping an explicit -# registry documents the facade and prevents static tooling from deleting aliases. -_COMPAT_EXPORTS = ( - Path, - RuntimeAppState, - describe_session_backend, - get_state, - _find_session_checkpoint, - _resolve_checkpoint_resume_input_from_session, - STATIC_DIR, - _CUSTOM_API_PROXY_ENV_KEYS, - _HOP_BY_HOP_HEADERS, - _MAX_INLINE_BASE64_CHARS, - _MAX_INLINE_TEXT_CHARS, - _MAX_REFERENCE_TEXT_BYTES, - _NATIVE_TUI_FRAMEWORKS, - _RESERVED_UI_PATHS, - _TEXT_FILE_EXTENSIONS, - _TEXT_MIME_PREFIXES, - _TEXT_MIME_TYPES, - _UPLOAD_URI_SCHEME, - _action_response, - _attachment_from_part, - _attachment_prompt_text, - _build_bootstrap_model_payload, - _build_native_terminal_capability, - _current_framework, - _custom_api_proxy_base_url, - _decode_inline_data, - _default_custom_ui_bundle_dir, - _ensure_runner_loaded, - _ensure_session, - _extract_inline_attachment_text, - _extract_pdf_text, - _extract_user_input_from_parts, - _hydrate_session, - _is_custom_ui_static_asset_path, - _is_textual_mime, - _looks_like_textual_attachment, - _normalize_request_ui_path, - _prepare_runner_for_model, - _proxy_headers, - _read_attachment_bytes, - _register_integrated_routers, - _request_id, - _resolve_active_runner, - _resolve_agent_ui_spec, - _resolve_attachment_storage_path, - _resolve_current_model, - _resolve_ui_static_response, - _resolve_uploads_dir, - _response_headers, - _runner_project_dir, - _sanitize_session_state_for_action, - _ui_state_with_env_fallback, - _workspace_root_dir, - _workspace_runtime_request, - custom_api_proxy, - health_check, - list_apps, - set_runner, - get_checkpoint_resume_preview_action, - resume_run_action, - subscribe_run_events_action, - _feedback_payload_from_state, - _feedback_state_key, - _find_feedback_assistant_event, - create_session, - delete_response_feedback_action, - delete_session, - get_agent_builder, - get_event_graph, - get_event_trace, - get_response_feedback_action, - get_session, - get_session_trace, - get_traces, - list_agent_models_action, - list_eval_results, - list_eval_sets, - list_sessions, - save_agent_builder, - save_session_to_memory, - serve_agent_ui_static, - upsert_response_feedback_action, - CancelRunActionRequest, - CreateSessionActionRequest, - GetCheckpointResumePreviewActionRequest, - ListSessionCheckpointsActionRequest, - ListSessionEventsActionRequest, - ListSessionMessagesActionRequest, - ListSessionsActionRequest, - ListToolReceiptsActionRequest, - ResponseFeedbackRefActionRequest, - ResponsesRequest, - ResumeRunActionRequest, - RunAgentActionRequest, - SessionIdRequest, - UiBootstrapRequest, - UpsertResponseFeedbackActionRequest, - WorkspaceDeleteActionRequest, - WorkspaceListActionRequest, - _EVENT_SCAN_PAGE_SIZE, - _MAX_PREVIEW_TOOL_RECEIPTS, - _RUN_ACTIVE_STATUSES, - _RUN_TERMINAL_STATUSES, - _clean_optional_string, - _event_run_id, - _event_text, - _latest_session_run_metadata, - _latest_session_run_status, - _metadata_invocation_id, - _parse_iso_datetime, - _resolve_responses_conversation_id, - _resolve_responses_session_and_user, - _run_agent_response_metadata, - _run_status_payload_status, - _runtime_agent_id, - _session_topic_from_events, - _session_user_prompt_from_event, - _split_custom_metadata, - _truncate_session_text, - ChatCompletionRequest, - chat_completions, - list_openai_models, - responses, - _agent_contains_invocation, - _SIDE_EFFECT_TOOL_NAMES, - _apply_adk_only_latest_resumable, - _apply_checkpoint_resume_audit, - _apply_latest_checkpoint_policy, - _build_checkpoint_resume_preview, - _check_adk_latest_resumable, - _checkpoint_event_to_action_payload, - _checkpoint_resume_disabled_detail, - _event_invocation_id, - _event_to_action_payload, - _extend_invocation_after_window, - _extend_invocation_before_window, - _iter_agent_event_pages, - _iter_scoped_event_pages, - _iter_session_event_pages, - _iter_with_idle_heartbeat, - _latest_invocation_status, - _oldest_unconsumed_session_events, - _record_resume_audit, - _require_action_session, - _resume_audit_by_checkpoint, - _session_contains_invocation, - _session_to_action_payload, - _tool_receipt_event_to_action_payload, - builder_router, - control_router, - debug_router, - feedback_router, - health_meta_router, - models_router, - openai_compat_router, - run_router, - sessions_adk_compat_router, - sessions_router, - tools_router, - ui_bootstrap_router, - workspace_router, - cancel_agent_changes, - run_agent_action, - run_sse, - _count_resumable_checkpoints, - _list_checkpoints_payload, - create_session_action, - delete_session_action, - get_agent_ui_bootstrap, - get_session_action, - list_session_checkpoints_action, - list_session_events_action, - list_session_messages_action, - list_sessions_action, - list_tool_receipts_action, - _cancel_detached_streams_for_session, - _detached_resume_key_from_input, - _reject_if_detached_resume_active, - ListAgentModelsRequest, - _build_models_payload, - _normalize_model_catalog_items, - attachment_content_action, - cancel_run_action, - delete_workspace_file_action, - export_workspace_zip, - get_workspace_file_content_action, - list_workspace_files_action, - upload_file_action, - upload_workspace_file_action, - workspace_file_path_route, -) - - -SSE_HEARTBEAT_INTERVAL_SECONDS = max( - 1.0, - float(os.getenv("AGENTENGINE_SSE_HEARTBEAT_SECONDS", "15")), -) - -_GROUP_ROUTERS = GROUP_ROUTERS -_configure_runtime_app = configure_runtime_app -_shutdown_runner_resources = shutdown_runtime_resources - - -def _detached_streaming_response( - source: AsyncIterator[str], - *, - invocation_id: str | None = None, - session_id: str | None = None, - resume_key: tuple[str, str] | None = None, - run_mode: str = "unknown", - run_trigger: str = "unknown", -) -> StreamingResponse: - """Build a detached response through the monkeypatch-compatible facade.""" - detached = _DetachedSSEStream( - source, - invocation_id=invocation_id, - session_id=session_id, - run_mode=run_mode, - run_trigger=run_trigger, - ) - registry = detached._registry - if invocation_id and resume_key: - registry.resume_keys_by_invocation[invocation_id] = resume_key - registry.active_resume_invocation_by_key[resume_key] = invocation_id - detached._task.add_done_callback( - lambda _task: _clear_detached_resume_key(registry, invocation_id, resume_key) - ) - return StreamingResponse(detached.iter_for_client(), media_type="text/event-stream") - - -route_dependencies.configure( - route_dependencies.ServerRouteDependencies( - resolve_session_service=lambda: resolve_session_service(), - describe_session_backend=lambda: describe_session_backend(), - resolve_agent_ui_spec=lambda: _resolve_agent_ui_spec(), - conversation=lambda: conversation, - detached_streaming_response=lambda *args, **kwargs: _detached_streaming_response( - *args, **kwargs - ), - detached_stream_class=lambda: _DetachedSSEStream, - heartbeat_interval=lambda: SSE_HEARTBEAT_INTERVAL_SECONDS, - runtime_app=lambda: app, - ) ) -app: FastAPI = create_runtime_app( - RuntimeAppConfig( - # Keep the documented facade injection seam dynamic. New factory users - # get app-owned services unless they explicitly supply a provider. - session_service_provider=lambda: resolve_session_service(), - session_backend_provider=lambda: describe_session_backend(), - ), - configure_runtime_app, -) -set_fallback_state(app.state.runtime) -terminal_manager = app.state.runtime.terminal_manager - - -def __getattr__(name: str) -> Any: - """Expose the default app's detached stream registry during migration.""" - if name == "_DETACHED_STREAMS_BY_INVOCATION": - return app.state.runtime.stream_registry.streams_by_invocation - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +__all__ = [ + "ALL_GROUPS", + "CONTROL_PLANE_GROUPS", + "DATA_PLANE_GROUPS", + "RuntimeAppConfig", + "RuntimeAppState", + "configure_runtime_app", + "create_runtime_app", +] diff --git a/ksadk/server/composition.py b/ksadk/server/composition.py index 4bbab4cc..e972b47a 100644 --- a/ksadk/server/composition.py +++ b/ksadk/server/composition.py @@ -2,14 +2,15 @@ from fastapi import FastAPI -from ksadk.server.factory import RuntimeAppState -from ksadk.server.routes.common import _register_integrated_routers +from ksadk.server.factory import RuntimeAppState, get_state +from ksadk.server.routes.common import _register_integrated_routers, _resolve_agent_ui_spec from ksadk.server.routes.routers import GROUP_ROUTERS, load_route_modules def configure_runtime_app(app: FastAPI, state: RuntimeAppState, groups: set[str]) -> None: """Attach integrated resources and the selected route groups to one app.""" load_route_modules() + _configure_route_dependencies() _register_integrated_routers(app, state) ordered = sorted(group for group in groups if group != "health_meta") @@ -19,3 +20,28 @@ def configure_runtime_app(app: FastAPI, state: RuntimeAppState, groups: set[str] router = GROUP_ROUTERS.get(group) if router is not None: app.include_router(router) + + +def _configure_route_dependencies() -> None: + """Install dynamic providers that always resolve the request-bound app state.""" + + import ksadk.conversations as conversation + from ksadk.server.routes import dependencies + from ksadk.server.routes.streaming import ( + SSE_HEARTBEAT_INTERVAL_SECONDS, + _DetachedSSEStream, + detached_streaming_response, + ) + + dependencies.configure( + dependencies.ServerRouteDependencies( + resolve_session_service=lambda: get_state().resolve_session_service(), + describe_session_backend=lambda: get_state().describe_session_backend(), + resolve_agent_ui_spec=_resolve_agent_ui_spec, + conversation=lambda: conversation, + detached_streaming_response=detached_streaming_response, + detached_stream_class=lambda: _DetachedSSEStream, + heartbeat_interval=lambda: SSE_HEARTBEAT_INTERVAL_SECONDS, + runtime_app=lambda: get_state().app, + ) + ) diff --git a/ksadk/server/factory.py b/ksadk/server/factory.py index 9478f288..7f13eea8 100644 --- a/ksadk/server/factory.py +++ b/ksadk/server/factory.py @@ -5,7 +5,7 @@ 设计要点: -- **per-app state**:runner / runner_loaded / detached-stream registry 全部挂在 +- **per-app state**:RuntimeExecutor / RuntimeLaunchContext / detached-stream registry 全部挂在 `app.state.runtime`(:class:`RuntimeAppState`)上,每个 app 实例一份,普通 app 与 HarnessApp 互不共享。不再有模块级 ``runner`` / ``_DETACHED_*`` 全局可变态。 - **请求级桥接**:中间件把当前 app 的 state 写入 :data:`current_state` 的 @@ -26,11 +26,12 @@ import contextvars import logging from contextlib import contextmanager -from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, cast +from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional from fastapi import FastAPI -from ksadk.runners.base_runner import BaseRunner +from ksadk.runtime.adapter import RuntimeAdapter, RuntimeLaunchContext +from ksadk.runtime.executor import RuntimeExecutor from ksadk.sandbox.registry import ( SandboxRegistry, bind_sandbox_registry, @@ -103,14 +104,15 @@ class RuntimeAppState: def __init__( self, - runner: Optional[BaseRunner] = None, *, + executor: RuntimeExecutor | None = None, + launch_context: RuntimeLaunchContext | None = None, session_service_provider: Callable[[], Any] | None = None, session_backend_provider: Callable[[], dict[str, Any]] | None = None, ) -> None: self.app: Optional[FastAPI] = None - self.runner: Optional[BaseRunner] = runner - self.runner_loaded: bool = False + self.executor = executor + self.launch_context = launch_context self.stream_registry = StreamRegistry() self.sandbox_registry = SandboxRegistry() self.session_service: Any = None @@ -175,8 +177,8 @@ def describe_session_backend(self) -> dict[str, Any]: class RuntimeAppConfig: """create_runtime_app 的装配配置。 - - ``runner``:依赖注入的 runner(替代 set_runner 全局态);可在装配后由 - 兼容壳 ``set_runner`` 再写入 ``app.state.runtime.runner``。 + - ``runtime_executor``:统一 Runtime 生命周期入口。 + - ``launch_context``:当前 Runtime 与项目的不可变启动上下文。 - ``runtime_type``:runtime 类型标识(普通 / harness / codex ...)。 - ``route_groups``:要装配的 route group 集合;默认 :data:`ALL_GROUPS`, HarnessApp 传 :data:`DATA_PLANE_GROUPS`。 @@ -184,17 +186,17 @@ class RuntimeAppConfig: def __init__( self, - runner: Optional[BaseRunner] = None, *, runtime_type: str = "local", route_groups: Optional[set[str]] = None, a2a: Optional[Any] = None, - runtime_adapter: Any = None, + a2a_runtime_adapter: RuntimeAdapter | None = None, agui: Optional[Any] = None, + runtime_executor: RuntimeExecutor | None = None, + launch_context: RuntimeLaunchContext | None = None, session_service_provider: Callable[[], Any] | None = None, session_backend_provider: Callable[[], dict[str, Any]] | None = None, ) -> None: - self.runner = runner self.runtime_type = runtime_type self.route_groups: set[str] = ( set(route_groups) if route_groups is not None else set(ALL_GROUPS) @@ -202,8 +204,10 @@ def __init__( # A2A 协议装配配置(``ksadk.a2a.routes.A2AConfig``);enabled 时 factory 装配 # A2A 数据面端点(契约 §8)。用 Any 避免本模块硬依赖可选的 a2a-sdk。 self.a2a = a2a - self.runtime_adapter = runtime_adapter + self.a2a_runtime_adapter = a2a_runtime_adapter self.agui = agui + self.runtime_executor = runtime_executor + self.launch_context = launch_context self.session_service_provider = session_service_provider self.session_backend_provider = session_backend_provider @@ -234,6 +238,17 @@ def get_state() -> RuntimeAppState: return state if state is not None else _fallback_state +def get_runtime_execution() -> tuple[RuntimeExecutor, RuntimeLaunchContext]: + """Resolve the current app's canonical runtime execution dependencies.""" + + from fastapi import HTTPException + + state = get_state() + if state.executor is None or state.launch_context is None: + raise HTTPException(status_code=500, detail="RuntimeExecutor 未初始化") + return state.executor, state.launch_context + + @contextmanager def bind_runtime_state(state: RuntimeAppState) -> Iterator[None]: """Bind one app state for non-HTTP scopes such as WebSockets.""" @@ -245,26 +260,6 @@ def bind_runtime_state(state: RuntimeAppState) -> Iterator[None]: _current_state.reset(token) -def get_runner() -> BaseRunner: - """取当前 app 的 runner(懒加载 agent)。等价旧的 ``_resolve_active_runner``。""" - from fastapi import HTTPException - - state = get_state() - runner = state.runner - if runner is None: - raise HTTPException(status_code=500, detail="Runner 未初始化") - if not state.runner_loaded: - try: - runner.load_agent() - except HTTPException: - raise - except Exception as exc: # noqa: BLE001 - logger.warning("Runner 加载失败: %s", exc) - raise HTTPException(status_code=500, detail=str(exc) or "Runner 加载失败") from exc - state.runner_loaded = True - return runner - - # --------------------------------------------------------------------------- # factory # --------------------------------------------------------------------------- @@ -290,14 +285,11 @@ async def shutdown_runtime_resources(state: RuntimeAppState) -> None: await asyncio.gather(*pending_streams, return_exceptions=True) registry.clear() - active_runner = state.runner - if active_runner is not None: - close = getattr(active_runner, "close", None) - if callable(close): - try: - await close() - except Exception: - logger.exception("failed to close runner on shutdown") + if state.executor is not None: + try: + await state.executor.close_all() + except Exception: + logger.exception("failed to close runtime adapters on shutdown") for service in state.session_services(): close = getattr(service, "aclose", None) @@ -313,6 +305,26 @@ async def shutdown_runtime_resources(state: RuntimeAppState) -> None: logger.exception("failed to clear sandbox registry on shutdown") +def _is_agent_execution_path(path: str, method: str) -> bool: + """是否 agent 执行类请求(需要 root span 兜底,让 tool 内 outbound 挂到该 trace)。 + + session 管理(GetAgentUiBootstrap/ListSessionMessages 等轮询)与 UI/health 不建 span。 + """ + m = (method or "").upper() + p = (path or "").strip() + if m == "POST" and p in { + "/v1/responses", + "/v1/chat/completions", + "/run", + "/run_sse", + "/agentengine/api/v1/RunAgent", + }: + return True + if m == "GET" and p in {"/agentengine/api/v1/SubscribeRunEvents"}: + return True + return False + + def create_runtime_app( config: RuntimeAppConfig, configure: Optional[ConfigureApp] = None, @@ -320,7 +332,7 @@ def create_runtime_app( """装配一个 runtime app。 参数: - config: :class:`RuntimeAppConfig`(runner / runtime_type / route_groups)。 + config: :class:`RuntimeAppConfig`(executor / launch_context / route_groups)。 configure: 路由装配回调 ``configure(app, state, route_groups)``;由 ``ksadk/server/app.py`` 提供,负责按 group 把 domain router include 进 app。 factory 自身只负责 FastAPI 实例、state、中间件、lifespan、异常处理器, @@ -333,7 +345,8 @@ def create_runtime_app( from fastapi.responses import Response state = RuntimeAppState( - runner=config.runner, + executor=config.runtime_executor, + launch_context=config.launch_context, session_service_provider=config.session_service_provider, session_backend_provider=config.session_backend_provider, ) @@ -370,8 +383,68 @@ async def _runtime_state_bridge(request, call_next): backend=state.describe_session_backend(), ), ): - # 保持旧行为:前端入口禁缓存。 - response = await call_next(request) + # 提取 inbound OTel trace context(traceparent);无有效 inbound 时建一个 + # 覆盖整个请求的 root server span,让 langchain/openinference 的 agent span 与 + # tool 内 outbound(A2A)调用都挂到这条 trace 上(openinference 只在 LLM 调用 + # 期间建 span,tool 执行时其 span 已 detach,需一个贯穿 span 兜底)。 + # 只对 agent 执行类路径建 root span;session/UI 管理路径(GetAgentUiBootstrap/ + # ListSessionMessages 等轮询)不建,避免一次问答产生一堆独立 trace。 + try: + from opentelemetry import context as _otel_ctx + from opentelemetry import propagate + from opentelemetry import trace as _otel_trace + + _carrier = dict(request.headers) + _parent = propagate.extract(_carrier) + _parent_span_ctx = _otel_trace.get_current_span(_parent).get_span_context() + except Exception: + logger.exception("failed to initialize request tracing; continuing without OTel") + response = await call_next(request) + else: + if _parent_span_ctx.is_valid: + try: + _token = _otel_ctx.attach(_parent) + except Exception: + logger.exception( + "failed to attach inbound trace context; continuing without OTel" + ) + response = await call_next(request) + else: + try: + response = await call_next(request) + finally: + try: + _otel_ctx.detach(_token) + except Exception: + logger.exception("failed to detach inbound trace context") + elif _is_agent_execution_path(request.url.path, request.method): + try: + _tracer = _otel_trace.get_tracer("ksadk.server") + _span_scope = _tracer.start_as_current_span( + f"{request.method} {request.url.path}" + ) + _span_scope.__enter__() + except Exception: + logger.exception( + "failed to start request span; continuing without OTel" + ) + response = await call_next(request) + else: + try: + response = await call_next(request) + except BaseException as exc: + try: + _span_scope.__exit__(type(exc), exc, exc.__traceback__) + except Exception: + logger.exception("failed to close request span after handler error") + raise + else: + try: + _span_scope.__exit__(None, None, None) + except Exception: + logger.exception("failed to close request span") + else: + response = await call_next(request) path = request.url.path if path == "/" or path.endswith(".html"): response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" @@ -415,67 +488,39 @@ async def session_backend_unavailable_handler(_request, exc: SessionBackendUnava return app -class _LazyRunnerProxy: - """把 app.state 的 runner 以惰性方式暴露给 A2A executor/adapter。 - - 兼容壳常在 factory 建 app 后才 ``set_runner`` 写入真实 runner,因此不能在装配期 - 固化 runner 引用。代理在每次调用时从 ``state`` 解析真实 runner 并懒加载, - 接口与 ``BaseRunner`` 对齐(load_agent / stream / invoke,其余属性经 __getattr__ - 转发)。 - """ - - def __init__(self, state: RuntimeAppState) -> None: - self.__dict__["_state"] = state - - def _real(self) -> BaseRunner: - state: RuntimeAppState = self.__dict__["_state"] - runner = state.runner - if runner is None: - raise RuntimeError("A2A: runner 尚未装配(set_runner 未调用)") - if not state.runner_loaded: - runner.load_agent() - state.runner_loaded = True - return runner - - def load_agent(self) -> BaseRunner: - return self._real() - - def stream(self, input_data: Any) -> Any: - # 与普通方法(非 async def)返回真实 runner.stream 的结果(async gen 或 coroutine), - # 由 RunnerRuntimeAdapter/executor 按既有分支处理。 - return self._real().stream(input_data) - - async def invoke(self, input_data: Any) -> Any: - return await self._real().invoke(input_data) - - def __getattr__(self, name: str) -> Any: - return getattr(self._real(), name) - - def _wire_a2a_if_enabled(app: FastAPI, state: RuntimeAppState, config: RuntimeAppConfig) -> None: - """契约 §8:``config.a2a.enabled`` 时把 A2A 协议端点装配进 app(数据面)。 + """Mount A2A with an explicitly injected RuntimeAdapter. - runner 经 :class:`_LazyRunnerProxy` 惰性解析;RuntimeAdapter 用通用 - ``RunnerRuntimeAdapter``(经 P0-2 后 cancel 走真实 asyncio 任务中断)。 + The server composition root never resolves or wraps a Runner. Framework + internals may still use one behind their adapter factory, but A2A only sees + the frozen RuntimeAdapter contract. """ a2a_cfg = config.a2a if a2a_cfg is None: return + from ksadk.managed_a2a_card import ManagedA2ACardMount + + if isinstance(a2a_cfg, ManagedA2ACardMount): + a2a_cfg.mount(app) + state.a2a_bootstrap = a2a_cfg + logger.info( + "managed A2A discovery card mounted(agent_name=%s base_url=%s)", + a2a_cfg.config.agent_name, + a2a_cfg.config.base_url, + ) + return from ksadk.a2a.bootstrap import AgentEngineA2ABootstrap - if isinstance(a2a_cfg, AgentEngineA2ABootstrap): - from ksadk.runtime.runner_adapter import RunnerRuntimeAdapter + adapter = config.a2a_runtime_adapter + if adapter is None: + raise ValueError("A2A requires an explicitly injected RuntimeAdapter") + runtime_type = adapter.runtime.runtime_type or config.runtime_type - proxy = _LazyRunnerProxy(state) - runtime_adapter = config.runtime_adapter or RunnerRuntimeAdapter( - cast("BaseRunner", proxy), - runtime_type=config.runtime_type, - ) + if isinstance(a2a_cfg, AgentEngineA2ABootstrap): server = a2a_cfg.mount( app, - runner=proxy, - runtime_adapter=runtime_adapter, - runtime_type=config.runtime_type, + runtime_adapter=adapter, + runtime_type=runtime_type, ) state.a2a_bootstrap = a2a_cfg state.a2a_server = server @@ -489,12 +534,9 @@ def _wire_a2a_if_enabled(app: FastAPI, state: RuntimeAppState, config: RuntimeAp return from ksadk.a2a.routes import add_a2a_protocol_routes from ksadk.a2a.task_adapter import A2ARuntimeTaskAdapter - from ksadk.runtime.runner_adapter import RunnerRuntimeAdapter - proxy = _LazyRunnerProxy(state) - adapter = RunnerRuntimeAdapter(cast("BaseRunner", proxy), runtime_type=config.runtime_type) - task_adapter = A2ARuntimeTaskAdapter(adapter, runtime_type=config.runtime_type) - server = add_a2a_protocol_routes(app, proxy, a2a_cfg, task_adapter=task_adapter) + task_adapter = A2ARuntimeTaskAdapter(adapter, runtime_type=runtime_type) + server = add_a2a_protocol_routes(app, a2a_cfg, task_adapter=task_adapter) state.a2a_server = server logger.info("A2A 协议端点已装配进 runtime app(agent=%s)", a2a_cfg.agent_name) @@ -517,7 +559,8 @@ def _wire_agui_if_enabled(app: FastAPI, state: RuntimeAppState, config: RuntimeA from ksadk.events.store import RuntimeEventStore from ksadk.server.routes import dependencies as route_dependencies - proxy = _LazyRunnerProxy(state) + if state.executor is None or state.launch_context is None: + raise ValueError("AG-UI requires RuntimeExecutor and RuntimeLaunchContext") class _AutoSessionRuntimeEventStore: """Create a session on first AG-UI event when HttpAgent starts fresh.""" @@ -551,7 +594,8 @@ async def list(self, session_id: str, **kwargs: Any) -> Any: agent = add_ksadk_agui_endpoint( app, - cast("BaseRunner", proxy), + state.executor, + state.launch_context, agui_cfg, event_store_factory=lambda: _AutoSessionRuntimeEventStore( route_dependencies.resolve_session_service() @@ -563,51 +607,6 @@ async def list(self, session_id: str, **kwargs: Any) -> Any: logger.info("AG-UI endpoint mounted at %s", agui_cfg.path) -def wire_default_agui_for_runner(state: RuntimeAppState, runner: BaseRunner) -> None: - """Mount AG-UI for legacy ``app`` + ``set_runner`` production entrypoints. - - Those entrypoints create the FastAPI app before the framework runner exists. - Mounting immediately after ``set_runner`` is still startup-time wiring. New - protocol routes are moved ahead of the static catch-all to preserve routing - order exactly as the normal factory path does. - """ - if state.app is None or state.agui_agent is not None: - return - - from ksadk.agui.config import default_agui_config - - agui_config = default_agui_config(runner) - if not agui_config.enabled: - return - - app = state.app - routes = app.router.routes - previous_count = len(routes) - _wire_agui_if_enabled( - app, - state, - RuntimeAppConfig( - runner=runner, - runtime_type=agui_config.runtime_type, - route_groups={"agui"}, - agui=agui_config, - ), - ) - new_routes = routes[previous_count:] - if not new_routes: - return - del routes[previous_count:] - catch_all_index = next( - ( - index - for index, route in enumerate(routes) - if getattr(route, "path", None) == "/{requested_path:path}" - ), - len(routes), - ) - routes[catch_all_index:catch_all_index] = new_routes - - __all__ = [ "ALL_GROUPS", "CONTROL_PLANE_GROUPS", @@ -617,8 +616,7 @@ def wire_default_agui_for_runner(state: RuntimeAppState, runner: BaseRunner) -> "bind_runtime_state", "StreamRegistry", "create_runtime_app", - "get_runner", + "get_runtime_execution", "get_state", "set_fallback_state", - "wire_default_agui_for_runner", ] diff --git a/ksadk/server/routes/common.py b/ksadk/server/routes/common.py index 7ace0066..36186622 100644 --- a/ksadk/server/routes/common.py +++ b/ksadk/server/routes/common.py @@ -16,12 +16,10 @@ from ksadk.conversations.attachments import compact_attachment_result_for_session from ksadk.conversations.model_context import normalize_model_metadata -from ksadk.runners.base_runner import BaseRunner from ksadk.runtime_state import load_state as load_runtime_state from ksadk.server.factory import ( RuntimeAppState, bind_runtime_state, - get_runner, get_state, ) from ksadk.server.terminal_sessions import ( @@ -102,11 +100,8 @@ def _workspace_root_dir() -> Path: def _current_framework() -> str: - runner = get_state().runner - if not runner: - return "" - detection_type = getattr(getattr(runner, "detection_result", None), "type", None) - return str(getattr(detection_type, "value", detection_type) or "").strip().lower() + context = get_state().launch_context + return str(context.runtime_type if context is not None else "").strip().lower() def _build_native_terminal_capability(framework: str) -> dict[str, Any]: @@ -145,32 +140,6 @@ def _register_integrated_routers(app: FastAPI, state: RuntimeAppState) -> None: ) -def set_runner(r: BaseRunner, *, loaded: bool = False): - """Bind a runner and record whether its agent has already been loaded.""" - state = get_state() - state.runner = r - state.runner_loaded = loaded - from ksadk.server.factory import wire_default_agui_for_runner - - wire_default_agui_for_runner(state, r) - - -def _ensure_runner_loaded() -> BaseRunner: - return get_runner() - - -def _resolve_active_runner() -> BaseRunner: - return get_runner() - - -def _prepare_runner_for_model(active_runner: BaseRunner, model: Optional[str]) -> None: - try: - active_runner.prepare_for_request(model) - except Exception as exc: - logger.warning("Runner 模型切换失败: %s", exc) - raise HTTPException(status_code=500, detail=str(exc) or "Runner 模型切换失败") from exc - - def _resolve_current_model() -> tuple[Optional[str], Optional[str]]: candidates = ( ("OPENAI_MODEL_NAME", os.getenv("OPENAI_MODEL_NAME")), @@ -194,11 +163,11 @@ def _build_bootstrap_model_payload() -> Optional[dict[str, Any]]: return payload -def _runner_project_dir() -> Path: - runner = get_state().runner - if runner and getattr(runner, "project_dir", None): +def _runtime_project_dir() -> Path: + context = get_state().launch_context + if context is not None: try: - return Path(str(runner.project_dir)).resolve() + return Path(str(context.project_dir)).resolve() except Exception: pass return Path(".").resolve() @@ -223,7 +192,7 @@ def _ui_state_with_env_fallback(state: dict[str, Any]) -> dict[str, Any]: def _resolve_agent_ui_spec() -> dict[str, Any]: - project_dir = _runner_project_dir() + project_dir = _runtime_project_dir() state = _ui_state_with_env_fallback(load_runtime_state(project_dir)) framework = _current_framework() auto_custom_bundle_dir = _default_custom_ui_bundle_dir(project_dir) @@ -696,18 +665,18 @@ async def _workspace_runtime_request( @health_meta_router.get("/health") async def health_check(): - runner = get_state().runner - framework = "unknown" - agent_name = "unknown" - if runner and hasattr(runner, "detection_result"): - framework = runner.detection_result.type.value # langgraph, langchain, adk - agent_name = runner.detection_result.name + context = get_state().launch_context + framework = context.runtime_type if context is not None else "unknown" + detection = context.detection if context is not None else None + agent_name = str(getattr(detection, "name", "") or "unknown") return {"status": "ok", "framework": framework, "agent": agent_name} @health_meta_router.get("/list-apps") async def list_apps(relative_path: str = "./"): """Return available apps. For KsADK single-agent mode, returns the current agent.""" - runner = get_state().runner - name = runner.detection_result.name if runner else "default_agent" + del relative_path + context = get_state().launch_context + detection = context.detection if context is not None else None + name = str(getattr(detection, "name", "") or "default_agent") return [name] diff --git a/ksadk/server/routes/control.py b/ksadk/server/routes/control.py index 94ca8cce..8fa5a881 100644 --- a/ksadk/server/routes/control.py +++ b/ksadk/server/routes/control.py @@ -17,10 +17,14 @@ RUN_MODE_FOREGROUND, RUN_TRIGGER_CHECKPOINT_RESUME, ) +from ksadk.conversations.runtime_payloads import build_responses_payload +from ksadk.conversations.runtime_streaming import stream_runtime_responses_conversation_turn +from ksadk.runtime.conversation_execution import invoke_runtime_conversation_once +from ksadk.server.factory import get_runtime_execution from . import dependencies as deps from .checkpoint_resolution import _find_session_checkpoint -from .common import _action_response, _prepare_runner_for_model, _resolve_active_runner +from .common import _action_response from .models import ( _MAX_PREVIEW_TOOL_RECEIPTS, _RUN_TERMINAL_STATUSES, @@ -108,6 +112,7 @@ async def get_checkpoint_resume_preview_action(request: GetCheckpointResumePrevi @control_router.post("/agentengine/api/v1/ResumeRun") async def resume_run_action(request: ResumeRunActionRequest): + executor, launch_context = get_runtime_execution() service = deps.resolve_session_service() session = await _require_action_session( service, @@ -174,7 +179,6 @@ async def resume_run_action(request: ResumeRunActionRequest): "resume_instruction_enabled": bool(getattr(request, "ResumeInstructionEnabled", False)), "resume_instruction": str(getattr(request, "ResumeInstruction", "") or "").strip(), } - active_runner = _resolve_active_runner() user_id = session.user_id or "user" custom_metadata, _metadata_runtime_controls = _split_custom_metadata(request.Metadata) resume_request_metadata = { @@ -201,8 +205,9 @@ async def resume_run_action(request: ResumeRunActionRequest): run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, ) return deps.detached_streaming_response( - deps.conversation().stream_responses_conversation_turn( - runner=active_runner, + stream_runtime_responses_conversation_turn( + executor=executor, + launch_context=launch_context, agent_id=request.AgentId, user_id=user_id, messages=[], @@ -215,7 +220,6 @@ async def resume_run_action(request: ResumeRunActionRequest): include_agentengine_metadata=True, resume_input=resume_input, invocation_id=resume_invocation_id, - prepare_runner=_prepare_runner_for_model, session_service_provider=deps.resolve_session_service, run_mode=RUN_MODE_BACKGROUND, ), @@ -226,8 +230,9 @@ async def resume_run_action(request: ResumeRunActionRequest): ) response_id = f"resp_{uuid.uuid4().hex}" - resolved_session_id, result = await deps.conversation().invoke_conversation_once( - runner=active_runner, + resolved_session_id, result = await invoke_runtime_conversation_once( + executor=executor, + launch_context=launch_context, agent_id=request.AgentId, user_id=user_id, messages=[], @@ -240,11 +245,10 @@ async def resume_run_action(request: ResumeRunActionRequest): resume_input=resume_input, response_id=response_id, invocation_id=str(resume_input["resume_attempt_id"]), - prepare_runner=_prepare_runner_for_model, session_service_provider=deps.resolve_session_service, run_mode=RUN_MODE_FOREGROUND, ) - payload = deps.conversation().build_responses_payload( + payload = build_responses_payload( output_text=result["output_text"], model=request.Model, session_id=resolved_session_id, diff --git a/ksadk/server/routes/misc.py b/ksadk/server/routes/misc.py index c9e28de3..de5925a4 100644 --- a/ksadk/server/routes/misc.py +++ b/ksadk/server/routes/misc.py @@ -16,7 +16,6 @@ from . import dependencies as deps from .common import ( _action_response, - _ensure_runner_loaded, _ensure_session, _hydrate_session, _resolve_ui_static_response, @@ -393,40 +392,18 @@ async def delete_session(app_name: str, user_id: str, session_id: str): "/apps/{app_name}/users/{user_id}/sessions/{session_id}/save_memory" ) async def save_session_to_memory(app_name: str, user_id: str, session_id: str): - """将指定 session 保存到长期记忆 - - 当配置了 KSADK_LTM_BACKEND 时,将 session 中的用户消息 - 持久化到长期记忆后端,供后续 session 通过 load_memory 工具检索。 - """ - active_runner = _ensure_runner_loaded() - - # 检查 runner 是否支持长期记忆 - from ksadk.runners.adk_runner import ADKRunner as _ADKRunner - - if not isinstance(active_runner, _ADKRunner): - raise HTTPException( - status_code=400, detail="Long-term memory is only supported with ADK runner" - ) - - if not active_runner._long_term_memory: - raise HTTPException( - status_code=400, - detail="Long-term memory not configured. Set KSADK_LTM_BACKEND environment variable.", - ) - - # 查找 ADK 内部 session ID - internal_session_id = active_runner._session_map.get(session_id, session_id) - - success = await active_runner.save_session_to_long_term_memory( - session_id=internal_session_id, - user_id=user_id, + """Fail closed until long-term memory is exposed as a Runtime capability.""" + + del app_name, user_id, session_id + raise HTTPException( + status_code=501, + detail={ + "code": "RUNTIME_NOT_SUPPORTED", + "message": "当前 RuntimeAdapter 未声明长期记忆写入能力", + "hint": "请通过 Runtime Catalog 检查长期记忆能力后重试", + }, ) - if success: - return {"status": "saved", "session_id": session_id} - else: - raise HTTPException(status_code=500, detail="Failed to save session to long-term memory") - @sessions_adk_compat_router.get( "/apps/{app_name}/users/{user_id}/sessions/{session_id}/events/{event_id}/graph" diff --git a/ksadk/server/routes/models.py b/ksadk/server/routes/models.py index a86acfda..55dc8731 100644 --- a/ksadk/server/routes/models.py +++ b/ksadk/server/routes/models.py @@ -11,7 +11,7 @@ from ksadk.conversations.run_kinds import RUN_MODE_UNKNOWN, RUN_TRIGGER_UNKNOWN from ksadk.conversations.run_status import RUN_STATUS_ACTIVE, RUN_STATUS_TERMINAL -from ksadk.runners.base_runner import BaseRunner +from ksadk.runtime.launch import RuntimeLaunchContext from ksadk.sessions import SessionEvent _RUN_TERMINAL_STATUSES = RUN_STATUS_TERMINAL @@ -258,11 +258,11 @@ def _resolve_responses_session_and_user(request: ResponsesRequest) -> tuple[str return resolved_session_id, resolved_user_id -def _runtime_agent_id(active_runner: BaseRunner) -> str: +def _runtime_agent_id(launch_context: RuntimeLaunchContext) -> str: runtime_id = _clean_optional_string(os.getenv("AGENT_RUNTIME_ID")) if runtime_id: return runtime_id - return str(getattr(active_runner.detection_result, "name", "") or "agent") + return str(getattr(launch_context.detection, "name", "") or "agent") def _metadata_invocation_id(metadata: Mapping[str, Any] | None) -> str | None: @@ -425,9 +425,17 @@ def _latest_session_run_metadata( if not _is_run_lifecycle_event(event) or _event_run_id(event) != invocation_id: continue metadata = event.metadata or {} - run_mode = str(metadata.get("run_mode") or RUN_MODE_UNKNOWN) - run_trigger = str(metadata.get("run_trigger") or RUN_TRIGGER_UNKNOWN) - break + candidate_mode = str(metadata.get("run_mode") or RUN_MODE_UNKNOWN) + candidate_trigger = str(metadata.get("run_trigger") or RUN_TRIGGER_UNKNOWN) + # Canonical RuntimeEvents and their legacy ``run_status`` projection + # coexist in the session log. RuntimeEvents intentionally carry only + # the transport-neutral marker, while the projection owns run kind + # metadata. Keep scanning past an unclassified RuntimeEvent so the UI + # does not downgrade a known background/foreground run to ``unknown``. + if candidate_mode != RUN_MODE_UNKNOWN or candidate_trigger != RUN_TRIGGER_UNKNOWN: + run_mode = candidate_mode + run_trigger = candidate_trigger + break return invocation_id, status, run_mode, run_trigger diff --git a/ksadk/server/routes/openai_compat.py b/ksadk/server/routes/openai_compat.py index c8d2688c..24a06028 100644 --- a/ksadk/server/routes/openai_compat.py +++ b/ksadk/server/routes/openai_compat.py @@ -9,11 +9,22 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel +from ksadk.conversations.normalize import normalize_kop_messages, normalize_responses_input from ksadk.conversations.run_kinds import RUN_MODE_FOREGROUND, trigger_from_resume_input +from ksadk.conversations.runtime_payloads import ( + build_chat_completions_payload, + build_responses_payload, + extract_responses_resume_input, +) +from ksadk.conversations.runtime_streaming import ( + stream_runtime_conversation_turn, + stream_runtime_responses_conversation_turn, +) +from ksadk.runtime.conversation_execution import invoke_runtime_conversation_once +from ksadk.server.factory import get_runtime_execution from . import dependencies as deps from .checkpoint_resolution import _resolve_checkpoint_resume_input_from_session -from .common import _prepare_runner_for_model, _resolve_active_runner from .models import ( ResponsesRequest, _clean_optional_string, @@ -23,7 +34,10 @@ _split_custom_metadata, ) from .routers import openai_compat_router -from .streaming import _detached_resume_key_from_input, _reject_if_detached_resume_active +from .streaming import ( + _detached_resume_key_from_input, + _reject_if_detached_resume_active, +) from .workspace import _build_models_payload @@ -57,11 +71,11 @@ async def list_openai_models(): @openai_compat_router.post("/v1/responses") async def responses(request: ResponsesRequest): """OpenAI Responses 兼容接口。""" - active_runner = _resolve_active_runner() + executor, launch_context = get_runtime_execution() resolved_session_id, resolved_user_id = _resolve_responses_session_and_user(request) - agent_id = _runtime_agent_id(active_runner) + agent_id = _runtime_agent_id(launch_context) - resume_input = deps.conversation().extract_responses_resume_input(request.input) + resume_input = extract_responses_resume_input(request.input) resume_input = await _resolve_checkpoint_resume_input_from_session( service=deps.resolve_session_service(), agent_id=agent_id, @@ -71,7 +85,7 @@ async def responses(request: ResponsesRequest): messages = ( [] if resume_input is not None - else deps.conversation().normalize_responses_input(request.input) + else normalize_responses_input(request.input) ) custom_metadata, request_metadata = _split_custom_metadata(request.metadata) if request.previous_response_id: @@ -90,11 +104,17 @@ async def responses(request: ResponsesRequest): invocation_id = _metadata_invocation_id(request_metadata) if request.stream: + runtime_preparation = ( + None + if resume_input is not None + else await executor.prepare_start(launch_context) + ) resume_key = _detached_resume_key_from_input(resolved_session_id, resume_input) _reject_if_detached_resume_active(resume_key) return deps.detached_streaming_response( - deps.conversation().stream_responses_conversation_turn( - runner=active_runner, + stream_runtime_responses_conversation_turn( + executor=executor, + launch_context=launch_context, agent_id=agent_id, user_id=resolved_user_id, messages=messages, @@ -108,9 +128,9 @@ async def responses(request: ResponsesRequest): resume_input=resume_input, account_id=account_id, invocation_id=invocation_id, - prepare_runner=_prepare_runner_for_model, session_service_provider=deps.resolve_session_service, run_mode=RUN_MODE_FOREGROUND, + runtime_preparation=runtime_preparation, ), invocation_id=invocation_id, resume_key=resume_key, @@ -119,8 +139,9 @@ async def responses(request: ResponsesRequest): ) response_id = f"resp_{uuid.uuid4().hex}" - resolved_session_id, result = await deps.conversation().invoke_conversation_once( - runner=active_runner, + resolved_session_id, result = await invoke_runtime_conversation_once( + executor=executor, + launch_context=launch_context, agent_id=agent_id, user_id=resolved_user_id, messages=messages, @@ -135,11 +156,10 @@ async def responses(request: ResponsesRequest): response_id=response_id, account_id=account_id, invocation_id=invocation_id, - prepare_runner=_prepare_runner_for_model, session_service_provider=deps.resolve_session_service, run_mode=RUN_MODE_FOREGROUND, ) - return deps.conversation().build_responses_payload( + return build_responses_payload( output_text=result["output_text"], model=request.model, session_id=resolved_session_id, @@ -152,18 +172,20 @@ async def responses(request: ResponsesRequest): @openai_compat_router.post("/v1/chat/completions") async def chat_completions(request: ChatCompletionRequest): """OpenAI 兼容的聊天补全接口 (支持流式和非流式)""" - active_runner = _resolve_active_runner() - messages = deps.conversation().normalize_kop_messages(request.messages) - agent_id = _runtime_agent_id(active_runner) + executor, launch_context = get_runtime_execution() + messages = normalize_kop_messages(request.messages) + agent_id = _runtime_agent_id(launch_context) resolved_user_id = _clean_optional_string(request.user) or "user" account_id = _clean_optional_string(request.account_id) custom_metadata, request_metadata = _split_custom_metadata(request.metadata) invocation_id = _metadata_invocation_id(request_metadata) if request.stream: + runtime_preparation = await executor.prepare_start(launch_context) return StreamingResponse( - deps.conversation().stream_conversation_turn( - runner=active_runner, + stream_runtime_conversation_turn( + executor=executor, + launch_context=launch_context, agent_id=agent_id, user_id=resolved_user_id, messages=messages, @@ -175,15 +197,16 @@ async def chat_completions(request: ChatCompletionRequest): custom_metadata=custom_metadata, invocation_id=invocation_id, account_id=account_id, - prepare_runner=_prepare_runner_for_model, session_service_provider=deps.resolve_session_service, run_mode=RUN_MODE_FOREGROUND, + runtime_preparation=runtime_preparation, ), media_type="text/event-stream", ) - resolved_session_id, result = await deps.conversation().invoke_conversation_once( - runner=active_runner, + resolved_session_id, result = await invoke_runtime_conversation_once( + executor=executor, + launch_context=launch_context, agent_id=agent_id, user_id=resolved_user_id, messages=messages, @@ -195,11 +218,10 @@ async def chat_completions(request: ChatCompletionRequest): custom_metadata=custom_metadata, invocation_id=invocation_id, account_id=account_id, - prepare_runner=_prepare_runner_for_model, session_service_provider=deps.resolve_session_service, run_mode=RUN_MODE_FOREGROUND, ) - return deps.conversation().build_chat_completions_payload( + return build_chat_completions_payload( output_text=result["output_text"], model=request.model, session_id=resolved_session_id, diff --git a/ksadk/server/routes/projection.py b/ksadk/server/routes/projection.py index beed2aed..4bb0ef51 100644 --- a/ksadk/server/routes/projection.py +++ b/ksadk/server/routes/projection.py @@ -15,8 +15,9 @@ build_fallback_title, build_heuristic_title, ) -from ksadk.server.factory import get_state -from ksadk.sessions import ConversationSessionCore, Session, SessionEvent +from ksadk.events.runtime_event import EventType +from ksadk.server.factory import get_runtime_execution, get_state +from ksadk.sessions import Session, SessionEvent from . import dependencies as deps from .common import _sanitize_session_state_for_action @@ -52,7 +53,6 @@ async def _require_action_session( async def _session_to_action_payload(session: Session) -> dict[str, Any]: - runner = get_state().runner events = list(session.events or []) if not events: try: @@ -106,20 +106,48 @@ async def _session_to_action_payload(session: Session) -> dict[str, Any]: "CreatedAt": session.created_at, "UpdatedAt": session.updated_at, "Version": session.version, + "Continuity": _runtime_continuity_payload(), } - if runner is not None: - try: - continuity = await runner.get_session_adapter().describe_continuity( - runner=runner, - session=session, - core=ConversationSessionCore(deps.resolve_session_service()), - ) - payload["Continuity"] = continuity.to_payload() - except Exception as exc: - logger.debug("Failed to describe continuity for session %s: %s", session.id, exc) return payload +def _runtime_continuity_payload() -> dict[str, Any]: + """Describe continuity from the active RuntimeAdapter capability contract.""" + + state = get_state() + if state.executor is None or state.launch_context is None: + # Session storage is independently useful in route-manifest and + # control-plane-only apps. Do not make a metadata projection require a + # live runtime execution binding. + return { + "Level": "semantic", + "Path": "replay", + "Runtime": "unbound", + "Details": { + "CheckpointSupported": False, + "Reason": "RuntimeAdapter is not bound to this app", + }, + } + executor, launch_context = get_runtime_execution() + capabilities = executor.native_capabilities(launch_context) + continuity = capabilities.get("SessionContinuity") + continuity = continuity if isinstance(continuity, Mapping) else {} + checkpoint = capabilities.get("Checkpoint") + checkpoint = checkpoint if isinstance(checkpoint, Mapping) else {} + level = str(continuity.get("Level") or "semantic").strip().lower() + if level not in {"ui_only", "semantic", "runtime", "exact"}: + level = "semantic" + return { + "Level": level, + "Path": "checkpoint" if checkpoint.get("Supported") else "replay", + "Runtime": launch_context.runtime_type, + "Details": { + "CheckpointSupported": bool(checkpoint.get("Supported")), + "Reason": str(continuity.get("Reason") or ""), + }, + } + + def _event_to_action_payload(event: SessionEvent) -> dict[str, Any]: payload = { "EventId": event.id, @@ -203,9 +231,34 @@ async def pump() -> None: def _checkpoint_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | None: - if event.event_type != "run_checkpoint": + if event.event_type == "run_checkpoint": + metadata = event.metadata or {} + elif event.event_type == EventType.CHECKPOINT_CREATED: + content = event.content or {} + payload = content.get("payload") if isinstance(content, Mapping) else {} + if not isinstance(payload, Mapping): + return None + framework_ref = payload.get("framework_ref") or payload.get("resume_target") or {} + if not isinstance(framework_ref, Mapping): + framework_ref = {} + framework = str(payload.get("framework") or "").strip() + if not framework and len(framework_ref) == 1: + framework = str(next(iter(framework_ref))) + capability = payload.get("capability") + capability = capability if isinstance(capability, Mapping) else {} + metadata = { + **dict(event.metadata or {}), + "run_id": str(payload.get("run_id") or event.invocation_id or ""), + "checkpoint_id": str(payload.get("checkpoint_id") or ""), + "framework": framework, + "framework_ref": dict(framework_ref), + "backend": str(payload.get("backend") or capability.get("backend") or "unknown"), + "scope": str(payload.get("scope") or capability.get("scope") or "unknown"), + "durable": bool(payload.get("durable", capability.get("durable", False))), + "is_resumable": bool(payload.get("is_resumable", True)), + } + else: return None - metadata = event.metadata or {} run_id = str(metadata.get("run_id") or "").strip() checkpoint_id = str(metadata.get("checkpoint_id") or "").strip() framework = str(metadata.get("framework") or "").strip() diff --git a/ksadk/server/routes/run.py b/ksadk/server/routes/run.py index 824d9eda..16298146 100644 --- a/ksadk/server/routes/run.py +++ b/ksadk/server/routes/run.py @@ -11,25 +11,45 @@ from fastapi.responses import StreamingResponse +from ksadk.conversations.normalize import ( + normalize_kop_messages, + normalize_parts_content, + normalize_responses_input, +) from ksadk.conversations.run_kinds import ( RUN_MODE_BACKGROUND, RUN_MODE_FOREGROUND, - RUN_TRIGGER_NEW_RUN, trigger_from_resume_input, ) +from ksadk.conversations.runtime_metadata import prime_session_metadata_for_user_turn +from ksadk.conversations.runtime_payloads import ( + build_chat_completions_payload, + build_responses_payload, + extract_responses_resume_input, +) +from ksadk.conversations.runtime_persistence import ( + append_run_status_event, + ensure_conversation_session, +) +from ksadk.conversations.runtime_streaming import ( + stream_runtime_conversation_turn, + stream_runtime_responses_conversation_turn, +) if TYPE_CHECKING: - from ksadk.conversations.runtime_payloads import PreparedConversationTurn + pass from ksadk.ids import new_run_id +from ksadk.runtime.conversation_execution import ( + invoke_runtime_conversation_once, + iter_runtime_conversation_semantic_events, +) from ksadk.server.api_models import AgentRunRequest +from ksadk.server.factory import get_runtime_execution from . import dependencies as deps from .checkpoint_resolution import _resolve_checkpoint_resume_input_from_session from .common import ( _action_response, - _ensure_runner_loaded, - _prepare_runner_for_model, - _resolve_active_runner, ) from .models import ( RunAgentActionRequest, @@ -38,11 +58,11 @@ _runtime_agent_id, _split_custom_metadata, ) -from .projection import _iter_with_idle_heartbeat from .routers import control_router, run_router from .streaming import ( _clear_detached_resume_key, _detached_resume_key_from_input, + _DetachedSSEStream, _reject_if_detached_resume_active, ) @@ -51,12 +71,13 @@ @run_router.post("/agentengine/api/v1/RunAgent") async def run_agent_action(request: RunAgentActionRequest): + executor, launch_context = get_runtime_execution() api_format = (request.ApiFormat or "responses").strip().lower() run_user_id = _clean_optional_string(request.UserId) or "user" account_id = _clean_optional_string(request.AccountId) service = deps.resolve_session_service() resume_input = ( - deps.conversation().extract_responses_resume_input(request.ResponsesInput) + extract_responses_resume_input(request.ResponsesInput) if request.ResponsesInput is not None else None ) @@ -69,9 +90,9 @@ async def run_agent_action(request: RunAgentActionRequest): if resume_input is not None: messages = [] elif request.ResponsesInput is not None and api_format == "responses": - messages = deps.conversation().normalize_responses_input(request.ResponsesInput) + messages = normalize_responses_input(request.ResponsesInput) else: - messages = deps.conversation().normalize_kop_messages(request.Messages) + messages = normalize_kop_messages(request.Messages) request_metadata: dict[str, Any] = ( {"previous_response_id": request.PreviousResponseId} if request.PreviousResponseId else {} ) @@ -81,11 +102,16 @@ async def run_agent_action(request: RunAgentActionRequest): request_metadata["responses_conversation"] = True if request.Background: + runtime_preparation = ( + None + if resume_input is not None + else await executor.prepare_start(launch_context) + ) invocation_id = request.InvocationId or new_run_id(request.SessionId) # 后台 stream 在 detached task 里才被消费(lazy),此时 session 尚未创建。 # 先 ensure 出 session,才能立刻写 run_status=in_progress(供 SubscribeRunEvents # 拉到起始态),并把 resolved session_id 回填给 detached stream 的终态写入与 SubscribeUrl。 - background_session = await deps.conversation().ensure_conversation_session( + background_session = await ensure_conversation_session( agent_id=request.AgentId, user_id=run_user_id, session_id=request.SessionId, @@ -93,14 +119,14 @@ async def run_agent_action(request: RunAgentActionRequest): ) resolved_background_session_id = background_session.id if resume_input is None: - await deps.conversation().prime_session_metadata_for_user_turn( + await prime_session_metadata_for_user_turn( service=service, session=background_session, messages=messages, ) - await deps.conversation().append_run_status_event( + await append_run_status_event( session_id=resolved_background_session_id, - author=_resolve_active_runner().detection_result.name, + author=_runtime_agent_id(launch_context), status="in_progress", invocation_id=invocation_id, session_service_provider=deps.resolve_session_service, @@ -109,9 +135,10 @@ async def run_agent_action(request: RunAgentActionRequest): ) resume_key = _detached_resume_key_from_input(resolved_background_session_id, resume_input) _reject_if_detached_resume_active(resume_key) - detached = deps.detached_stream_class()( - deps.conversation().stream_responses_conversation_turn( - runner=_resolve_active_runner(), + detached = _DetachedSSEStream( + stream_runtime_responses_conversation_turn( + executor=executor, + launch_context=launch_context, agent_id=request.AgentId, user_id=run_user_id, messages=messages, @@ -125,9 +152,9 @@ async def run_agent_action(request: RunAgentActionRequest): resume_input=resume_input, account_id=account_id, invocation_id=invocation_id, - prepare_runner=_prepare_runner_for_model, session_service_provider=deps.resolve_session_service, run_mode=RUN_MODE_BACKGROUND, + runtime_preparation=runtime_preparation, ), invocation_id=invocation_id, session_id=resolved_background_session_id, @@ -135,14 +162,15 @@ async def run_agent_action(request: RunAgentActionRequest): run_trigger=trigger_from_resume_input(resume_input), ) if invocation_id and resume_key: + resolved_resume_key = resume_key registry = detached._registry - registry.resume_keys_by_invocation[invocation_id] = resume_key - registry.active_resume_invocation_by_key[resume_key] = invocation_id - detached._task.add_done_callback( - lambda _t, inv=invocation_id, rk=resume_key: _clear_detached_resume_key( - registry, inv, rk - ) - ) + registry.resume_keys_by_invocation[invocation_id] = resolved_resume_key + registry.active_resume_invocation_by_key[resolved_resume_key] = invocation_id + + def clear_resume_key(_task: Any) -> None: + _clear_detached_resume_key(registry, invocation_id, resolved_resume_key) + + detached._task.add_done_callback(clear_resume_key) return _action_response( "RunAgent", { @@ -159,12 +187,17 @@ async def run_agent_action(request: RunAgentActionRequest): ) if request.Stream: + runtime_preparation = ( + None + if resume_input is not None + else await executor.prepare_start(launch_context) + ) if api_format == "chat_completions": - active_runner = _resolve_active_runner() return StreamingResponse( - deps.conversation().stream_conversation_turn( - runner=active_runner, - agent_id=_runtime_agent_id(active_runner), + stream_runtime_conversation_turn( + executor=executor, + launch_context=launch_context, + agent_id=_runtime_agent_id(launch_context), user_id=run_user_id, messages=messages, session_id=request.SessionId, @@ -175,17 +208,18 @@ async def run_agent_action(request: RunAgentActionRequest): custom_metadata=custom_metadata, account_id=account_id, invocation_id=request.InvocationId, - prepare_runner=_prepare_runner_for_model, session_service_provider=deps.resolve_session_service, run_mode=RUN_MODE_FOREGROUND, + runtime_preparation=runtime_preparation, ), media_type="text/event-stream", ) resume_key = _detached_resume_key_from_input(request.SessionId, resume_input) _reject_if_detached_resume_active(resume_key) return deps.detached_streaming_response( - deps.conversation().stream_responses_conversation_turn( - runner=_resolve_active_runner(), + stream_runtime_responses_conversation_turn( + executor=executor, + launch_context=launch_context, agent_id=request.AgentId, user_id=run_user_id, messages=messages, @@ -199,9 +233,9 @@ async def run_agent_action(request: RunAgentActionRequest): resume_input=resume_input, account_id=account_id, invocation_id=request.InvocationId, - prepare_runner=_prepare_runner_for_model, session_service_provider=deps.resolve_session_service, run_mode=RUN_MODE_FOREGROUND, + runtime_preparation=runtime_preparation, ), invocation_id=request.InvocationId, resume_key=resume_key, @@ -210,8 +244,9 @@ async def run_agent_action(request: RunAgentActionRequest): ) responses_response_id = f"resp_{uuid.uuid4().hex}" if api_format != "chat_completions" else None - resolved_session_id, result = await deps.conversation().invoke_conversation_once( - runner=_resolve_active_runner(), + resolved_session_id, result = await invoke_runtime_conversation_once( + executor=executor, + launch_context=launch_context, agent_id=request.AgentId, user_id=run_user_id, messages=messages, @@ -225,20 +260,19 @@ async def run_agent_action(request: RunAgentActionRequest): response_id=responses_response_id, account_id=account_id, invocation_id=request.InvocationId, - prepare_runner=_prepare_runner_for_model, session_service_provider=deps.resolve_session_service, run_mode=RUN_MODE_FOREGROUND, ) output_text = result["output_text"] if api_format == "chat_completions": - payload = deps.conversation().build_chat_completions_payload( + payload = build_chat_completions_payload( output_text=output_text, model=request.Model, session_id=resolved_session_id, metadata=result.get("metadata"), ) else: - payload = deps.conversation().build_responses_payload( + payload = build_responses_payload( output_text=output_text, model=request.Model, session_id=resolved_session_id, @@ -254,467 +288,226 @@ async def run_agent_action(request: RunAgentActionRequest): # ============================================================ -@run_router.post("/run_sse") -async def run_sse(request: AgentRunRequest): - """Unified Streaming Endpoint compatible with ADK Web +def _adk_sse_event(payload: Mapping[str, Any], *, event: str | None = None) -> str: + prefix = f"event: {event}\n" if event else "" + return f"{prefix}data: {json.dumps(dict(payload), ensure_ascii=False)}\n\n" - Respects the `streaming` parameter: - - streaming=False: Accumulate full response, send as single event - - streaming=True: Stream tokens as they arrive (real-time) - """ - active_runner = _ensure_runner_loaded() - _prepare_runner_for_model(active_runner, request.model) - use_streaming = request.streaming - normalized_message = deps.conversation().normalize_parts_content( - request.newMessage.parts if request.newMessage else [] + +def _adk_usage_metadata(usage: Mapping[str, Any] | None) -> dict[str, int] | None: + if not isinstance(usage, Mapping): + return None + prompt = usage.get("input_tokens", usage.get("prompt_tokens")) + candidates = usage.get("output_tokens", usage.get("completion_tokens")) + if not isinstance(prompt, int) or not isinstance(candidates, int): + return None + return { + "promptTokenCount": prompt, + "candidatesTokenCount": candidates, + "totalTokenCount": prompt + candidates, + } + + +async def _runtime_run_sse(request: AgentRunRequest) -> StreamingResponse: + executor, launch_context = get_runtime_execution() + # Validate explicit ownership before StreamingResponse commits HTTP 200. + # Also resolve an omitted id exactly once so preparation cannot create a + # second, orphaned session after the response generator starts. + execution_session = await ensure_conversation_session( + agent_id=request.appName, + user_id=request.userId, + session_id=request.sessionId, + session_service_provider=deps.resolve_session_service, ) - user_message = { + execution_session_id = execution_session.id + normalized = normalize_parts_content(request.newMessage.parts) + message = { "role": "user", - "content": str(normalized_message.get("content") or ""), - "display_content": str(normalized_message.get("display_content") or ""), - "parts": list(normalized_message.get("parts") or []), - "attachments": list(normalized_message.get("attachments") or []), - "attachment_results": list(normalized_message.get("attachment_results") or []), + "content": str(normalized.get("content") or ""), + "display_content": str(normalized.get("display_content") or ""), + "parts": list(normalized.get("parts") or []), + "attachments": list(normalized.get("attachments") or []), + "attachment_results": list(normalized.get("attachment_results") or []), } - - model_version = "models/gemini-pro" if "gemini" in request.appName.lower() else "models/unknown" - prepared_non_stream: PreparedConversationTurn | None = None - if request.sessionId: - await deps.conversation().ensure_conversation_session( - agent_id=request.appName, - user_id=request.userId, - session_id=request.sessionId, - session_service_provider=deps.resolve_session_service, - ) - if not use_streaming: - prepared_non_stream = await deps.conversation().build_run_input( - agent_id=request.appName, - user_id=request.userId, - session_id=request.sessionId, - messages=[user_message], - state_delta=request.stateDelta or {}, - invocation_id=request.invocationId, - session_service_provider=deps.resolve_session_service, - ) - await deps.conversation().append_run_status_event( - session_id=prepared_non_stream.session_id, - author=active_runner.detection_result.name, - status="in_progress", - invocation_id=prepared_non_stream.invocation_id, - session_service_provider=deps.resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=RUN_TRIGGER_NEW_RUN, - ) + invocation_id = request.invocationId or new_run_id(execution_session_id) + author = _runtime_agent_id(launch_context) async def event_generator(): - if not use_streaming: + if not request.streaming: try: - assert prepared_non_stream is not None - session_id = prepared_non_stream.session_id - user_input = prepared_non_stream.user_input - attachments = prepared_non_stream.attachments - attachment_results = prepared_non_stream.attachment_results - current_attachments = prepared_non_stream.current_attachments - current_attachment_results = prepared_non_stream.current_attachment_results - input_content = prepared_non_stream.input_content - input_messages = prepared_non_stream.input_messages - user_parts = prepared_non_stream.user_parts - history = prepared_non_stream.history - invocation_id = prepared_non_stream.invocation_id - common_metadata = { - "modelVersion": model_version, - "usageMetadata": { - "promptTokenCount": len(user_input), - "candidatesTokenCount": 0, - "totalTokenCount": len(user_input), - }, - } - input_data = { - "session_id": session_id, - "input": user_input, - "history": history, - "input_content": list(input_content), - "input_messages": list(input_messages), - "input_parts": list(user_parts), - "attachments": attachments, - "attachment_results": attachment_results, - "current_attachments": current_attachments, - "current_attachment_results": current_attachment_results, - "has_current_files": prepared_non_stream.has_current_files, - "model": request.model, - } - result = await active_runner.invoke(input_data) - final_text = result.get("output", "") - response_event = { - "id": str(uuid.uuid4()), - "author": active_runner.detection_result.name, - "sessionId": session_id, - "invocationId": invocation_id, - "content": {"role": "model", "parts": [{"text": final_text}]}, - "actions": {"finishReason": "STOP"}, - "modelVersion": common_metadata["modelVersion"], - "usageMetadata": { - "promptTokenCount": len(user_input), - "candidatesTokenCount": len(final_text), - "totalTokenCount": len(user_input) + len(final_text), - }, - "timestamp": int(time.time() * 1000), - } - yield f"data: {json.dumps(response_event, ensure_ascii=False)}\n\n" - if final_text: - await deps.conversation().append_conversation_event( - session_id=session_id, - author=active_runner.detection_result.name, - role="model", - text=final_text, - invocation_id=invocation_id, - event_type="assistant_message", - session_service_provider=deps.resolve_session_service, - ) - await deps.conversation().append_run_status_event( - session_id=session_id, - author=active_runner.detection_result.name, - status="completed", - invocation_id=invocation_id, - session_service_provider=deps.resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=RUN_TRIGGER_NEW_RUN, - ) - - except Exception: - logger.exception("Error in invoke") - public_error = "The agent run failed. See server logs for details." - await deps.conversation().append_run_status_event( - session_id=session_id, - author=active_runner.detection_result.name, - status="failed", + session_id, result = await invoke_runtime_conversation_once( + executor=executor, + launch_context=launch_context, + agent_id=request.appName, + user_id=request.userId, + messages=[message], + session_id=execution_session_id, + model=request.model, + state_delta=request.stateDelta or {}, invocation_id=invocation_id, - detail=public_error, session_service_provider=deps.resolve_session_service, run_mode=RUN_MODE_FOREGROUND, - run_trigger=RUN_TRIGGER_NEW_RUN, ) - error_event = { + output_text = str(result.get("output_text") or "") + payload: dict[str, Any] = { "id": str(uuid.uuid4()), + "author": author, "sessionId": session_id, "invocationId": invocation_id, - "error": "agent_run_failed", - "errorMessage": public_error, + "content": {"role": "model", "parts": [{"text": output_text}]}, + "actions": {"finishReason": "STOP"}, + "modelVersion": request.model or "models/unknown", "timestamp": int(time.time() * 1000), } - yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" - else: - try: - compaction_preview = await deps.conversation().preview_auto_compaction( - agent_id=request.appName, - user_id=request.userId, - session_id=request.sessionId, - messages=[user_message], - session_service_provider=deps.resolve_session_service, + usage_metadata = _adk_usage_metadata(result.get("usage")) + if usage_metadata is not None: + payload["usageMetadata"] = usage_metadata + yield _adk_sse_event(payload) + except Exception: + logger.exception("Error in RuntimeAdapter invoke") + yield _adk_sse_event( + { + "id": str(uuid.uuid4()), + "sessionId": execution_session_id, + "invocationId": invocation_id, + "error": "agent_run_failed", + "errorMessage": "The agent run failed. See server logs for details.", + "timestamp": int(time.time() * 1000), + } ) - if compaction_preview.should_compact: - yield deps.conversation().build_compaction_sse_event( - phase="start", - trigger="auto", - total_chars=compaction_preview.total_chars, - group_count=compaction_preview.group_count, - ) + return - prepared = await deps.conversation().build_run_input( - agent_id=request.appName, - user_id=request.userId, - session_id=request.sessionId, - messages=[user_message], - state_delta=request.stateDelta or {}, - invocation_id=request.invocationId, - session_service_provider=deps.resolve_session_service, - ) - if prepared.compaction_triggered: - yield deps.conversation().build_compaction_sse_event( - phase="done", - trigger=str(prepared.compaction_trigger or "auto"), - compacted_until_seq_id=prepared.compacted_until_seq_id, - total_chars=( - compaction_preview.total_chars - if compaction_preview.should_compact - else None - ), - group_count=( - compaction_preview.group_count - if compaction_preview.should_compact - else None - ), + session_id = execution_session_id + visible_text = "" + try: + async for event in iter_runtime_conversation_semantic_events( + executor=executor, + launch_context=launch_context, + agent_id=request.appName, + user_id=request.userId, + messages=[message], + session_id=execution_session_id, + model=request.model, + state_delta=request.stateDelta or {}, + invocation_id=invocation_id, + session_service_provider=deps.resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, + ): + event_type = event.get("type") + session_id = str(event.get("session_id") or session_id) + if event_type == "thinking": + yield _adk_sse_event( + {"delta": str(event.get("delta") or "")}, + event="response.reasoning.delta", ) - - session_id = prepared.session_id - user_input = prepared.user_input - attachments = prepared.attachments - attachment_results = prepared.attachment_results - current_attachments = prepared.current_attachments - current_attachment_results = prepared.current_attachment_results - input_content = prepared.input_content - input_messages = prepared.input_messages - user_parts = prepared.user_parts - history = prepared.history - invocation_id = prepared.invocation_id - common_metadata = { - "modelVersion": model_version, - "usageMetadata": { - "promptTokenCount": len(user_input), - "candidatesTokenCount": 0, - "totalTokenCount": len(user_input), - }, - } - await deps.conversation().append_run_status_event( - session_id=session_id, - author=active_runner.detection_result.name, - status="in_progress", - invocation_id=invocation_id, - session_service_provider=deps.resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=RUN_TRIGGER_NEW_RUN, - ) - - client_visible_text = "" - authoritative_text = "" - responses_output: list[Any] = [] - responses_response_id: str | None = None - stream_iter = active_runner.stream( - { - "session_id": session_id, - "input": user_input, - "history": history, - "input_content": list(input_content), - "input_messages": list(input_messages), - "input_parts": list(user_parts), - "attachments": attachments, - "attachment_results": attachment_results, - "current_attachments": current_attachments, - "current_attachment_results": current_attachment_results, - "has_current_files": prepared.has_current_files, - "model": request.model, + elif event_type == "compaction": + phase = str(event.get("phase") or "done") + yield _adk_sse_event( + { + key: value + for key, value in event.items() + if key not in {"type", "phase"} and value is not None + }, + event=f"response.compaction.{phase}", + ) + elif event_type == "text": + delta = str(event.get("delta") or "") + replace = bool(event.get("replace")) + visible_text = delta if replace else visible_text + delta + text_payload: dict[str, Any] = { + "id": str(uuid.uuid4()), + "author": author, + "sessionId": session_id, + "invocationId": invocation_id, + "content": {"role": "model", "parts": [{"text": delta}]}, + "timestamp": int(time.time() * 1000), } - ) - async for kind, chunk in _iter_with_idle_heartbeat(stream_iter): - if kind == "heartbeat": - yield ": ping\n\n" - continue - event_id = str(uuid.uuid4()) - if chunk.get("type") == "responses_output": - raw_output = chunk.get("output") - responses_output = raw_output if isinstance(raw_output, list) else [] - raw_response_id = chunk.get("response_id") - responses_response_id = ( - str(raw_response_id) if raw_response_id else responses_response_id + if replace: + # A replacement snapshot is authoritative final output, + # not another partial token. Keep the replacement hint + # so clients can discard previously rendered deltas. + text_payload.update( + { + "replace": True, + "actions": {"finishReason": "STOP"}, + "modelVersion": request.model or "models/unknown", + } ) - continue - if chunk.get("type") == "thinking": - delta = str(chunk.get("delta", "")) - if delta: - await deps.conversation().append_reasoning_event( - session_id=session_id, - author=active_runner.detection_result.name, - text=delta, - invocation_id=invocation_id, - session_service_provider=deps.resolve_session_service, - ) - yield ( - "event: response.reasoning.delta\n" - f"data: {json.dumps({'delta': delta}, ensure_ascii=False)}\n\n" - ) - continue - if chunk.get("type") == "text": - delta_text = chunk.get("delta", "") - client_visible_text += delta_text - authoritative_text = client_visible_text - response_event = { - "id": event_id, - "author": chunk.get("node", active_runner.detection_result.name), - "sessionId": session_id, - "invocationId": invocation_id, - "content": {"role": "model", "parts": [{"text": delta_text}]}, - "partial": True, - "timestamp": int(time.time() * 1000), - } - yield f"data: {json.dumps(response_event, ensure_ascii=False)}\n\n" - continue - if chunk.get("type") == "tool_call": - yield ( - "event: response.tool_call\n" - "data: " - + json.dumps( - { - "name": chunk.get("tool_name"), - "args": chunk.get("tool_args", {}), + else: + text_payload.update({"partial": True, "replace": False}) + yield _adk_sse_event( + text_payload + ) + elif event_type == "tool_call": + yield _adk_sse_event( + {"name": event.get("name"), "args": event.get("args") or {}}, + event="response.tool_call", + ) + elif event_type == "tool_result": + yield _adk_sse_event( + {"name": event.get("name"), "output": event.get("output")}, + event="response.tool_result", + ) + elif event_type == "interrupt": + yield _adk_sse_event( + {"interrupt_info": event.get("interrupt_info")}, + event="response.approval_request", + ) + elif event_type == "completed": + final_text = str(event.get("output_text") or visible_text) + if final_text != visible_text: + yield _adk_sse_event( + { + "id": str(uuid.uuid4()), + "author": author, + "sessionId": session_id, + "invocationId": invocation_id, + "content": { + "role": "model", + "parts": [{"text": final_text}], }, - ensure_ascii=False, - ) - + "\n\n" + "actions": {"finishReason": "STOP"}, + "modelVersion": request.model or "models/unknown", + "timestamp": int(time.time() * 1000), + } ) - tool_event = { - "id": event_id, - "author": chunk.get("node", "tool"), + elif event_type == "error": + yield _adk_sse_event( + { + "id": str(uuid.uuid4()), "sessionId": session_id, "invocationId": invocation_id, - "content": { - "role": "model", - "parts": [ - { - "functionCall": { - "name": chunk.get("tool_name", "unknown"), - "args": chunk.get("tool_args", {}), - } - } - ], - }, - "actions": { - "finishReason": "STOP", - "stateDelta": {}, - }, - "modelVersion": common_metadata["modelVersion"], + "error": "agent_run_failed", + "errorMessage": str(event.get("message") or "Agent run failed"), "timestamp": int(time.time() * 1000), } - yield f"data: {json.dumps(tool_event, ensure_ascii=False)}\n\n" - await deps.conversation().append_conversation_event( - session_id=session_id, - author=chunk.get("node", "tool"), - role="model", - text="", - invocation_id=invocation_id, - event_type="tool_call", - session_service_provider=deps.resolve_session_service, - metadata={ - "tool_name": chunk.get("tool_name", "unknown"), - "tool_args": chunk.get("tool_args", {}), - }, - ) - continue - if chunk.get("type") == "tool_result": - await deps.conversation().append_conversation_event( - session_id=session_id, - author=active_runner.detection_result.name, - role="user", - text=str(chunk.get("tool_output", "")), - invocation_id=invocation_id, - event_type="tool_result", - session_service_provider=deps.resolve_session_service, - metadata={ - "tool_name": chunk.get("tool_name"), - "tool_output": chunk.get("tool_output", {}), - }, - ) - yield ( - "event: response.tool_result\n" - "data: " - + json.dumps( - { - "name": chunk.get("tool_name"), - "output": chunk.get("tool_output", {}), - }, - ensure_ascii=False, - ) - + "\n\n" - ) - continue - if chunk.get("type") == "interrupt": - await deps.conversation().append_conversation_event( - session_id=session_id, - author=active_runner.detection_result.name, - role="model", - text="approval requested", - invocation_id=invocation_id, - event_type="approval_request", - session_service_provider=deps.resolve_session_service, - metadata={"interrupt_info": chunk.get("interrupt_info")}, - ) - yield ( - "event: response.approval_request\n" - "data: " - + json.dumps( - {"interrupt_info": chunk.get("interrupt_info")}, - ensure_ascii=False, - ) - + "\n\n" - ) - continue - if chunk.get("type") == "final": - final_text = chunk.get("output", "") - if not final_text: - continue - authoritative_text = final_text - if final_text != client_visible_text: - final_event = { - "id": event_id, - "author": active_runner.detection_result.name, - "sessionId": session_id, - "invocationId": invocation_id, - "content": {"role": "model", "parts": [{"text": final_text}]}, - "actions": {"finishReason": "STOP"}, - "modelVersion": common_metadata["modelVersion"], - "usageMetadata": { - "promptTokenCount": len(user_input), - "candidatesTokenCount": len(final_text), - "totalTokenCount": len(user_input) + len(final_text), - }, - "timestamp": int(time.time() * 1000), - } - yield f"data: {json.dumps(final_event, ensure_ascii=False)}\n\n" - client_visible_text = final_text - - if authoritative_text: - await deps.conversation().append_conversation_event( - session_id=session_id, - author=active_runner.detection_result.name, - role="model", - text=authoritative_text, - invocation_id=invocation_id, - event_type="assistant_message", - metadata={ - **({"responses_output": responses_output} if responses_output else {}), - **( - {"response_id": responses_response_id} - if responses_response_id - else {} - ), - }, - session_service_provider=deps.resolve_session_service, ) - await deps.conversation().append_run_status_event( - session_id=session_id, - author=active_runner.detection_result.name, - status="completed", - invocation_id=invocation_id, - session_service_provider=deps.resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=RUN_TRIGGER_NEW_RUN, - ) - - except Exception: - logger.exception("Error in stream") - public_error = "The agent run failed. See server logs for details." - await deps.conversation().append_run_status_event( - session_id=session_id, - author=active_runner.detection_result.name, - status="failed", - invocation_id=invocation_id, - detail=public_error, - session_service_provider=deps.resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=RUN_TRIGGER_NEW_RUN, - ) - error_event = { + except Exception: + logger.exception("Error in RuntimeAdapter stream") + yield _adk_sse_event( + { "id": str(uuid.uuid4()), "sessionId": session_id, "invocationId": invocation_id, "error": "agent_run_failed", - "errorMessage": public_error, + "errorMessage": "The agent run failed. See server logs for details.", "timestamp": int(time.time() * 1000), } - yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" + ) return StreamingResponse(event_generator(), media_type="text/event-stream") +@run_router.post("/run_sse") +async def run_sse(request: AgentRunRequest): + """Unified Streaming Endpoint compatible with ADK Web + + Respects the `streaming` parameter: + - streaming=False: Accumulate full response, send as single event + - streaming=True: Stream tokens as they arrive (real-time) + """ + return await _runtime_run_sse(request) + + # ============================================================ # Trace / Debug API (ADK Web Compatible) # ============================================================ diff --git a/ksadk/server/routes/sessions.py b/ksadk/server/routes/sessions.py index 31ae96e7..b58a05e0 100644 --- a/ksadk/server/routes/sessions.py +++ b/ksadk/server/routes/sessions.py @@ -7,7 +7,7 @@ from fastapi import HTTPException -from ksadk.server.factory import get_runner, get_state +from ksadk.server.factory import get_runtime_execution, get_state from ksadk.sessions import SessionEvent from ksadk.tools.gateway import tool_approval_capability from ksadk.toolsets import describe_agentengine_tools @@ -23,6 +23,7 @@ _build_native_terminal_capability, _ensure_session, _hydrate_session, + _resolve_agent_ui_spec, ) from .models import ( CreateSessionActionRequest, @@ -58,45 +59,41 @@ @ui_bootstrap_router.post("/agentengine/api/v1/GetAgentUiBootstrap") async def get_agent_ui_bootstrap(request: UiBootstrapRequest): state = get_state() - runner = state.runner - if runner is not None: - runner = get_runner() - agent_id = request.AgentId or (_runtime_agent_id(runner) if runner else "default-agent") - description = getattr(runner.detection_result, "description", "") if runner else "" - framework = "" - if runner: - detection_type = getattr(getattr(runner, "detection_result", None), "type", None) - framework = str(getattr(detection_type, "value", detection_type) or "").strip().lower() + executor, launch_context = get_runtime_execution() + detection = launch_context.detection + agent_id = request.AgentId or _runtime_agent_id(launch_context) + description = str(getattr(detection, "description", "") or "") + framework = launch_context.runtime_type.strip().lower() workspace_enabled = workspace_files_enabled(default=True) - ui_spec = deps.resolve_agent_ui_spec() - runtime_capabilities = ( - runner.get_runtime_capabilities() - if runner and callable(getattr(runner, "get_runtime_capabilities", None)) - else {} + ui_spec = _resolve_agent_ui_spec() + runtime_capabilities = executor.native_capabilities(launch_context) + resume_capability = ( + runtime_capabilities.get("ResumeRun") + if isinstance(runtime_capabilities, Mapping) + else None + ) + resume_capability = resume_capability if isinstance(resume_capability, Mapping) else {} + checkpoint_capability = ( + runtime_capabilities.get("Checkpoint") + if isinstance(runtime_capabilities, Mapping) + else None ) + checkpoint_capability = ( + checkpoint_capability if isinstance(checkpoint_capability, Mapping) else {} + ) + cancel_capability = ( + runtime_capabilities.get("CancelRun") + if isinstance(runtime_capabilities, Mapping) + else None + ) + cancel_capability = cancel_capability if isinstance(cancel_capability, Mapping) else {} checkpoint_resume_capability = { - "Supported": bool( - (runtime_capabilities.get("ResumeRun") or {}).get("Supported") - if isinstance(runtime_capabilities, Mapping) - else False - ), - "Checkpoint": ( - (runtime_capabilities.get("Checkpoint") or {}) - if isinstance(runtime_capabilities, Mapping) - else {} - ), - "ResumeRun": ( - (runtime_capabilities.get("ResumeRun") or {}) - if isinstance(runtime_capabilities, Mapping) - else {} - ), + "Supported": bool(resume_capability.get("Supported")), + "Checkpoint": checkpoint_capability, + "ResumeRun": resume_capability, } checkpoint_resume_supported = bool(checkpoint_resume_capability["Supported"]) - cancel_run_supported = bool( - (runtime_capabilities.get("CancelRun") or {}).get("Supported") - if isinstance(runtime_capabilities, Mapping) - else False - ) + cancel_run_supported = bool(cancel_capability.get("Supported")) responses_transport = { "Protocol": "responses", "Runtime": "ksadk", @@ -137,7 +134,7 @@ async def get_agent_ui_bootstrap(request: UiBootstrapRequest): { "Agent": { "AgentId": agent_id, - "Name": runner.detection_result.name if runner else agent_id, + "Name": str(getattr(detection, "name", "") or agent_id), "Description": description or "", "Framework": framework, }, @@ -258,15 +255,13 @@ async def delete_session_action(request: SessionIdRequest): @sessions_router.post("/agentengine/api/v1/ListSessionEvents") async def list_session_events_action(request: ListSessionEventsActionRequest): - runner = get_state().runner + _executor, launch_context = get_runtime_execution() service = deps.resolve_session_service() session_id = str(request.SessionId or "").strip() if not session_id: # 未传 SessionId:返回该 agent 全部会话的事件(存储层跨会话查询,真分页无截断; # seq 游标是会话内序号,跨会话模式下不适用,直接忽略) - agent_id = str(request.AgentId or "").strip() or ( - _runtime_agent_id(runner) if runner else "default-agent" - ) + agent_id = str(request.AgentId or "").strip() or _runtime_agent_id(launch_context) events = await service.get_events_for_agent( agent_id, user_id=request.UserId, diff --git a/ksadk/server/routes/streaming.py b/ksadk/server/routes/streaming.py index be8deaf0..50739e1e 100644 --- a/ksadk/server/routes/streaming.py +++ b/ksadk/server/routes/streaming.py @@ -4,18 +4,24 @@ import asyncio import logging +import os from collections.abc import AsyncIterator, Mapping from typing import Any from fastapi import HTTPException +from fastapi.responses import StreamingResponse +from ksadk.conversations.runtime_persistence import append_run_status_event from ksadk.server.factory import get_state -from . import dependencies as deps from .models import _RUN_TERMINAL_STATUSES from .projection import _latest_invocation_status logger = logging.getLogger(__name__) +SSE_HEARTBEAT_INTERVAL_SECONDS = max( + 0.01, + float(os.getenv("AGENTENGINE_SSE_HEARTBEAT_SECONDS", "15")), +) _RESERVED_UI_PATHS = {"/", "/chat", "/build", "/deploy"} _CUSTOM_API_PROXY_ENV_KEYS = ("KSADK_USER_BACKEND_URL", "LUOLUO_USER_BACKEND_URL") @@ -33,6 +39,13 @@ } +def _observe_detached_task_result(task: asyncio.Task[Any]) -> None: + """Consume a detached task exception after ``_consume`` has logged it.""" + if task.cancelled(): + return + task.exception() + + class _DetachedSSEStream: _MAX_BACKLOG_CHUNKS = 256 @@ -58,6 +71,7 @@ def __init__( self._task = asyncio.create_task(self._consume()) self._registry.streams.add(self._task) self._task.add_done_callback(self._registry.streams.discard) + self._task.add_done_callback(_observe_detached_task_result) if self.invocation_id: self._registry.streams_by_invocation[self.invocation_id] = self self._task.add_done_callback( @@ -70,7 +84,7 @@ async def _has_terminal_run_status(self) -> bool: if not self.session_id or not self.invocation_id: return False status = await _latest_invocation_status( - deps.resolve_session_service(), + get_state().resolve_session_service(), self.session_id, self.invocation_id, ) @@ -108,7 +122,7 @@ async def _consume(self) -> None: if terminal_fallback_status and self.session_id: try: if not await self._has_terminal_run_status(): - await deps.conversation().append_run_status_event( + await append_run_status_event( session_id=self.session_id, author="system", status=terminal_fallback_status, @@ -116,7 +130,7 @@ async def _consume(self) -> None: detail=( f"background_{terminal_fallback_status}:{self.invocation_id or ''}" ), - session_service_provider=deps.resolve_session_service, + session_service_provider=get_state().resolve_session_service, run_mode=self._run_mode, run_trigger=self._run_trigger, ) @@ -151,7 +165,7 @@ async def iter_for_client(self) -> AsyncIterator[str]: try: chunk = await asyncio.wait_for( queue.get(), - timeout=deps.heartbeat_interval(), + timeout=SSE_HEARTBEAT_INTERVAL_SECONDS, ) except asyncio.TimeoutError: yield ": ping\n\n" @@ -221,3 +235,31 @@ def _reject_if_detached_resume_active(resume_key: tuple[str, str] | None) -> Non "run_id": resume_key[1], }, ) + + +def detached_streaming_response( + source: AsyncIterator[str], + *, + invocation_id: str | None = None, + session_id: str | None = None, + resume_key: tuple[str, str] | None = None, + run_mode: str = "unknown", + run_trigger: str = "unknown", +) -> StreamingResponse: + """Create an app-owned detached SSE response with reconnectable backlog.""" + + detached = _DetachedSSEStream( + source, + invocation_id=invocation_id, + session_id=session_id, + run_mode=run_mode, + run_trigger=run_trigger, + ) + registry = detached._registry + if invocation_id and resume_key: + registry.resume_keys_by_invocation[invocation_id] = resume_key + registry.active_resume_invocation_by_key[resume_key] = invocation_id + detached._task.add_done_callback( + lambda _task: _clear_detached_resume_key(registry, invocation_id, resume_key) + ) + return StreamingResponse(detached.iter_for_client(), media_type="text/event-stream") diff --git a/ksadk/server/routes/workspace.py b/ksadk/server/routes/workspace.py index 2ad84ad0..35be0304 100644 --- a/ksadk/server/routes/workspace.py +++ b/ksadk/server/routes/workspace.py @@ -4,6 +4,7 @@ import io import logging +import time import uuid import zipfile from pathlib import PurePosixPath @@ -16,7 +17,8 @@ from ksadk.conversations.attachment_storage import AttachmentStorageService from ksadk.conversations.model_context import normalize_model_metadata -from ksadk.server.factory import get_state +from ksadk.runtime.adapter import CancelResult +from ksadk.server.factory import get_runtime_execution, get_state from ksadk_runtime_common.workspace_files.preview import ( build_workspace_file_base_href, build_workspace_preview_csp, @@ -26,7 +28,6 @@ from . import dependencies as deps from .common import ( _action_response, - _resolve_active_runner, _resolve_current_model, _workspace_root_dir, _workspace_runtime_request, @@ -134,6 +135,7 @@ async def delete_workspace_file_action(request: WorkspaceDeleteActionRequest): @control_router.post("/agentengine/api/v1/CancelRun") async def cancel_run_action(request: CancelRunActionRequest): + executor, launch_context = get_runtime_execution() detached = get_state().stream_registry.streams_by_invocation.get(request.InvocationId) service = deps.resolve_session_service() scoped_session_id = str(request.SessionId or "").strip() @@ -145,6 +147,15 @@ async def cancel_run_action(request: CancelRunActionRequest): ) if not scoped_session_id and detached is not None: scoped_session_id = detached_session_id + handle = ( + executor.find_handle( + launch_context.runtime_type, + request.InvocationId, + scoped_session_id, + ) + if scoped_session_id + else None + ) if scoped_session_id: await _require_action_session( service, @@ -152,7 +163,7 @@ async def cancel_run_action(request: CancelRunActionRequest): agent_id=request.AgentId, user_id=request.UserId, ) - if detached is None and not await _session_contains_invocation( + if detached is None and handle is None and not await _session_contains_invocation( service, scoped_session_id, request.InvocationId, @@ -171,33 +182,31 @@ async def cancel_run_action(request: CancelRunActionRequest): user_id=request.UserId, ): raise HTTPException(status_code=404, detail="Invocation not found") - found = detached is not None + found = detached is not None or handle is not None cancel_requested = False if detached is not None: cancel_requested = detached.cancel() - runner_cancel_status = "not_found" if found else "unsupported" - active_runner = _resolve_active_runner() - if active_runner is not None: + runtime_cancel_status = "detached_task_cancelled" if cancel_requested else "not_running" + if handle is not None and detached is None: try: - runner_result = active_runner.request_cancel(request.InvocationId) - if isinstance(runner_result, str) and runner_result: - runner_cancel_status = runner_result - elif runner_result is True: - runner_cancel_status = "accepted" - elif runner_result is False and not found: - runner_cancel_status = "not_found" - except Exception as exc: - runner_cancel_status = "error" + runtime_result = await executor.cancel(handle) + runtime_cancel_status = runtime_result.value + except Exception as exc: # noqa: BLE001 + runtime_cancel_status = CancelResult.FAILED.value logger.warning("CancelRun failed: %s", exc) - runner_accepted = runner_cancel_status in {"accepted", "cancelling", "cancelled"} - status = "cancelling" if found or runner_accepted else runner_cancel_status + runtime_accepted = runtime_cancel_status in { + "detached_task_cancelled", + CancelResult.INTERRUPTED_ACTIVE_TURN.value, + CancelResult.PENDING_CANCEL_RECORDED.value, + } + status = "cancelling" if runtime_accepted else runtime_cancel_status return _action_response( "CancelRun", { - "Cancelled": bool(cancel_requested or runner_accepted), + "Cancelled": bool(cancel_requested or runtime_accepted), "Found": found, "Status": status, - "RunnerCancelStatus": runner_cancel_status, + "RuntimeCancelStatus": runtime_cancel_status, }, ) @@ -324,6 +333,11 @@ def _normalize_model_catalog_items(raw_models: list[Any]) -> list[dict[str, Any] return sorted(normalized_by_id.values(), key=lambda item: item["id"]) +_MODELS_CATALOG_TTL_SECONDS = 60.0 +# api_base -> (monotonic_ts, payload)。进程内短 TTL 缓存,见 _build_models_payload。 +_MODELS_CATALOG_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} + + async def _build_models_payload() -> dict[str, Any]: import os @@ -344,6 +358,18 @@ def _fallback_catalog() -> dict[str, Any]: if not api_base: return _fallback_catalog() + # 上游 /v1/models 是一次真实外网往返(数百毫秒),会话页初始化会 + # 调用本接口,不能每次都同步打上游。做短 TTL 进程内缓存: + # 命中直接返回;上游失败时优先回退到过期的缓存值,避免模型列表 + # 因上游抖动整体不可用。 + cache_key = api_base.rstrip("/") + cached = _MODELS_CATALOG_CACHE.get(cache_key) + now = time.monotonic() + if cached is not None and now - cached[0] < _MODELS_CATALOG_TTL_SECONDS: + payload = dict(cached[1]) + payload["cached"] = True + return payload + try: base_url = api_base.rstrip("/") if base_url.endswith("/v1"): @@ -368,9 +394,16 @@ def _fallback_catalog() -> dict[str, Any]: str(item.get("id") or "").strip() != current_model for item in models ): models = _normalize_model_catalog_items([*models, current_model]) - return {"data": models, "current": current_model, "source": source} + payload = {"data": models, "current": current_model, "source": source} + _MODELS_CATALOG_CACHE[cache_key] = (now, payload) + return dict(payload) except Exception as e: logger.error(f"Failed to fetch models: {e}") + if cached is not None: + payload = dict(cached[1]) + payload["stale"] = True + payload["error"] = str(e) + return payload fallback = _fallback_catalog() fallback["error"] = str(e) return fallback diff --git a/ksadk/skills/runtime/backends/e2b.py b/ksadk/skills/runtime/backends/e2b.py index ebd83069..a99c4d1f 100644 --- a/ksadk/skills/runtime/backends/e2b.py +++ b/ksadk/skills/runtime/backends/e2b.py @@ -77,7 +77,7 @@ def __init__( @classmethod def from_env(cls) -> "E2BSkillRuntimeBackend": try: - from e2b import Sandbox # type: ignore[import-untyped] + from e2b import Sandbox # type: ignore[import-not-found, import-untyped] except ImportError as exc: raise SkillRuntimeError( "e2b>=2.15.3,<2.25.0 is required for KSADK_SKILL_RUNTIME_BACKEND=e2b" diff --git a/ksadk/skills/service_client.py b/ksadk/skills/service_client.py index 597c148b..5f2bd9e9 100644 --- a/ksadk/skills/service_client.py +++ b/ksadk/skills/service_client.py @@ -18,6 +18,12 @@ "aicp.api.ksyun.com", } ) +_PRIVATE_KOP_HOSTS = frozenset( + { + "aicp.inner.api.ksyun.com", + "aicp.internal.api.ksyun.com", + } +) class SkillServiceClient: @@ -215,14 +221,18 @@ def _requests(self) -> requests.Session: return self._requests_session def _is_kop_mode(self) -> bool: - """Match only exact, TLS-protected AICP control-plane endpoints. + """Match only exact AICP control-plane endpoints. A user-controlled URL such as ``aicp.api.ksyun.com.attacker.example`` - must never receive KOP signing headers. + must never receive KOP signing headers. Public AICP requires TLS; the + private ``inner`` and ``internal`` endpoints also support their existing + in-cluster HTTP transport. """ parsed = urlsplit(self.base_url) host = (parsed.hostname or "").lower() - return parsed.scheme == "https" and host in _KOP_HOSTS + return host in _KOP_HOSTS and ( + parsed.scheme == "https" or (parsed.scheme == "http" and host in _PRIVATE_KOP_HOSTS) + ) def _kop_base_url(self) -> str: parsed = urlsplit(self.base_url) diff --git a/ksadk/studio/__init__.py b/ksadk/studio/__init__.py new file mode 100644 index 00000000..c46beb45 --- /dev/null +++ b/ksadk/studio/__init__.py @@ -0,0 +1,5 @@ +"""Local-first AgentKit Studio control plane.""" + +from ksadk.studio.contracts import AgentDraft, AgentSpec + +__all__ = ["AgentDraft", "AgentSpec"] diff --git a/ksadk/studio/agent_avatar_assets.py b/ksadk/studio/agent_avatar_assets.py new file mode 100644 index 00000000..63a0514b --- /dev/null +++ b/ksadk/studio/agent_avatar_assets.py @@ -0,0 +1,120 @@ +"""Validated, content-addressed assets for Studio Agent avatars.""" + +from __future__ import annotations + +import hashlib +import io +import re +import warnings +from pathlib import Path + +from PIL import Image, UnidentifiedImageError + +from ksadk.studio.errors import StudioError, not_found +from ksadk.studio.workspace import Workspace + + +class AgentAvatarAssetStore: + MAX_BYTES = 2 * 1024 * 1024 + MIN_EDGE = 32 + MAX_EDGE = 2048 + _ASSET_NAME = re.compile(r"^(?P[0-9a-f]{64})\.(?Ppng|webp)$") + _MIME_BY_FORMAT = {"PNG": "image/png", "WEBP": "image/webp"} + _EXTENSION_BY_FORMAT = {"PNG": "png", "WEBP": "webp"} + + def __init__(self, workspace: Workspace) -> None: + self.workspace = workspace + + def store(self, content: bytes, *, content_type: str) -> dict: + if not content: + raise StudioError( + "AGENT_AVATAR_EMPTY", + "头像文件不能为空", + status_code=422, + field="file", + ) + if len(content) > self.MAX_BYTES: + raise StudioError( + "AGENT_AVATAR_TOO_LARGE", + "头像文件不能超过 2 MiB", + status_code=413, + field="file", + details={"maxBytes": self.MAX_BYTES}, + ) + normalized_type = content_type.split(";", 1)[0].strip().lower() + if normalized_type not in self._MIME_BY_FORMAT.values(): + raise StudioError( + "AGENT_AVATAR_TYPE_UNSUPPORTED", + "头像仅支持 PNG 或 WebP", + status_code=415, + field="file", + ) + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + with Image.open(io.BytesIO(content)) as image: + image.load() + image_format = str(image.format or "").upper() + width, height = image.size + except ( + UnidentifiedImageError, + OSError, + Image.DecompressionBombError, + Image.DecompressionBombWarning, + ) as exc: + raise StudioError( + "AGENT_AVATAR_INVALID", + "头像文件不是有效图片", + status_code=422, + field="file", + ) from exc + + expected_type = self._MIME_BY_FORMAT.get(image_format) + if expected_type is None or expected_type != normalized_type: + raise StudioError( + "AGENT_AVATAR_TYPE_MISMATCH", + "头像内容与文件类型不一致", + status_code=422, + field="file", + ) + if width != height: + raise StudioError( + "AGENT_AVATAR_NOT_SQUARE", + "头像必须裁剪为 1:1 正方形", + status_code=422, + field="file", + details={"width": width, "height": height}, + ) + if width < self.MIN_EDGE or width > self.MAX_EDGE: + raise StudioError( + "AGENT_AVATAR_DIMENSIONS_INVALID", + "头像边长必须在 32 到 2048 像素之间", + status_code=422, + field="file", + details={"width": width, "height": height}, + ) + + digest = hashlib.sha256(content).hexdigest() + extension = self._EXTENSION_BY_FORMAT[image_format] + asset_name = f"{digest}.{extension}" + path = self.workspace.resolve(Path(".agentkit/assets/agent-avatars") / asset_name) + if not path.is_file(): + self.workspace.atomic_write_bytes(path, content) + return { + "digest": digest, + "url": f"/api/v1/assets/agent-avatars/{asset_name}", + "mimeType": expected_type, + "size": len(content), + "width": width, + "height": height, + } + + def get(self, asset_name: str) -> tuple[Path, str]: + match = self._ASSET_NAME.fullmatch(asset_name) + if match is None: + raise not_found("agent-avatar", asset_name) + extension = match.group("extension") + path = self.workspace.resolve(Path(".agentkit/assets/agent-avatars") / asset_name) + if not path.is_file(): + raise not_found("agent-avatar", asset_name) + return path, "image/png" if extension == "png" else "image/webp" diff --git a/ksadk/studio/agent_lifecycle.py b/ksadk/studio/agent_lifecycle.py new file mode 100644 index 00000000..e160a21e --- /dev/null +++ b/ksadk/studio/agent_lifecycle.py @@ -0,0 +1,51 @@ +"""Recoverable deletion for generic Studio Agents.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from ksadk.studio.contracts import RunStatus +from ksadk.studio.errors import StudioError + + +def delete_framework_agent(studio: Any, agent_id: str, *, purge: bool) -> None: + studio.drafts.get(agent_id) + running = [ + run.id + for run in studio.event_store.list_runs(agent_id=agent_id) + if run.status in {RunStatus.CREATED, RunStatus.RUNNING} + ] + if running: + raise StudioError( + "AGENT_RUN_ACTIVE", + "Agent 仍有运行中的任务,请等待运行结束后再删除", + status_code=409, + details={"agentId": agent_id, "runIds": running}, + ) + trash_directory = None + if not purge: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + trash_directory = studio.workspace.resolve( + Path(".agentkit/trash/agents") / f"{agent_id}-{timestamp}" + ) + trash_directory.mkdir(parents=True, exist_ok=False) + studio.event_store.delete_agent( + agent_id, + purge=purge, + trash_directory=trash_directory, + ) + studio.builds.delete_for_agent( + agent_id, + purge=purge, + trash_directory=trash_directory, + ) + studio.drafts.delete( + agent_id, + purge=purge, + trash_directory=trash_directory, + ) + + +__all__ = ["delete_framework_agent"] diff --git a/ksadk/studio/api.py b/ksadk/studio/api.py new file mode 100644 index 00000000..bf3824e7 --- /dev/null +++ b/ksadk/studio/api.py @@ -0,0 +1,1047 @@ +"""FastAPI application for the loopback-only AgentKit Studio control plane.""" + +from __future__ import annotations + +import asyncio +import hmac +import json +import os +import secrets +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, Literal + +from fastapi import FastAPI, File, Header, Query, Request, Response, UploadFile +from fastapi.exceptions import RequestValidationError +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles + +from ksadk.studio.api_catalog_routes import register_catalog_routes +from ksadk.studio.api_contracts import ( + AuthoringCommitRequest, + BuildRequest, + ConversationAuthoringRequest, + CreateAgentRequest, + EvaluationRequest, + InteractionSubmitRequest, + ProjectInspectRequest, + QuickAuthoringRequest, + RollbackRequest, + RunRequest, + SessionExchangeRequest, + StudioEvaluationCreate, + ValidationRequest, + WorkspaceOpenRequest, +) +from ksadk.studio.api_helpers import ( + error_response as _error_response, +) +from ksadk.studio.api_helpers import ( + is_local_origin, +) +from ksadk.studio.api_helpers import ( + optional_int as _optional_int, +) +from ksadk.studio.api_helpers import ( + parse_revision as _parse_revision, +) +from ksadk.studio.api_helpers import ( + require_idempotency_key as _require_idempotency_key, +) +from ksadk.studio.api_helpers import ( + responses_input as _responses_input, +) +from ksadk.studio.api_helpers import ( + responses_session_id as _responses_session_id, +) +from ksadk.studio.api_helpers import ( + sse as _sse, +) +from ksadk.studio.codex_manifest import CodexAgentManifest +from ksadk.studio.contracts import ( + AgentAppearance, + AgentBindings, + AgentSpec, + AgentTemplateComposeRequest, + DeploymentRequest, + OperationStatus, +) +from ksadk.studio.errors import StudioError +from ksadk.studio.service import StudioService +from ksadk.studio.shared_web import StudioSharedWebBridge + +_WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} +_PUBLIC_API_PATHS = { + "/api/v1/system/health", + "/api/v1/system/session", +} +_LOCAL_HOSTS = {"127.0.0.1", "::1", "localhost", "testserver"} + + +def create_studio_app( + root: Path | str, + *, + service: StudioService | None = None, + session_token: str | None = None, + csrf_token: str | None = None, + security_enabled: bool = True, +) -> FastAPI: + studio = service or StudioService(root) + session_secret = session_token or secrets.token_urlsafe(32) + csrf_secret = csrf_token or secrets.token_urlsafe(24) + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + try: + yield + finally: + studio.credentials.clear_session() + + app = FastAPI( + title="AgentKit Local Studio", + version="1.0.0", + docs_url=None, + redoc_url=None, + openapi_url="/api/v1/openapi.json", + lifespan=lifespan, + ) + app.state.studio_service = studio + app.state.session_token = session_secret + app.state.csrf_token = csrf_secret + static_root = Path(__file__).with_name("static") + shared_web = StudioSharedWebBridge(studio) + app.state.shared_web_bridge = shared_web + app.mount("/static", StaticFiles(directory=static_root), name="studio-static") + + @app.middleware("http") + async def local_security(request: Request, call_next): + request.state.request_id = request.headers.get("X-Request-Id") or ( + f"req_{secrets.token_hex(12)}" + ) + host = (request.url.hostname or "").lower() + if host not in _LOCAL_HOSTS: + return _error_response( + StudioError( + "LOCAL_HOST_FORBIDDEN", + "Studio 只接受 loopback Host", + status_code=403, + ), + request, + ) + origin = request.headers.get("Origin") + if origin and not is_local_origin(origin, local_hosts=_LOCAL_HOSTS): + return _error_response( + StudioError( + "LOCAL_ORIGIN_FORBIDDEN", + "请求 Origin 不是本地 Studio", + status_code=403, + ), + request, + ) + content_length = request.headers.get("Content-Length") + large_upload_paths = { + "/api/v1/catalog/skills:import": 52 * 1024 * 1024, + "/api/v1/authoring/imports:inspect": 102 * 1024 * 1024, + } + request_limit = large_upload_paths.get(request.url.path, 2 * 1024 * 1024) + if content_length and int(content_length) > request_limit: + return _error_response( + StudioError( + "REQUEST_TOO_LARGE", + "请求体超过 2 MiB 限制", + status_code=413, + ), + request, + ) + studio_api = request.url.path.startswith("/api/v1") + shared_web_api = request.url.path.startswith("/agentengine/api/v1") + responses_api = request.url.path == "/v1/responses" or request.url.path.startswith( + "/v1/responses/" + ) + if security_enabled and ( + (studio_api and request.url.path not in _PUBLIC_API_PATHS) + or shared_web_api + or responses_api + ): + supplied = request.cookies.get("agentkit_studio_session") or request.headers.get( + "X-AgentKit-Session" + ) + if not supplied or not hmac.compare_digest(supplied, session_secret): + return _error_response( + StudioError( + "LOCAL_SESSION_REQUIRED", + "缺少有效的 Studio 本地会话", + status_code=401, + ), + request, + ) + if (studio_api or responses_api) and request.method in _WRITE_METHODS: + csrf = request.headers.get("X-CSRF-Token") + if not csrf or not hmac.compare_digest(csrf, csrf_secret): + return _error_response( + StudioError( + "CSRF_TOKEN_INVALID", + "缺少有效的 CSRF token", + status_code=403, + ), + request, + ) + response = await call_next(request) + response.headers["X-Request-Id"] = request.state.request_id + response.headers["Cache-Control"] = ( + "no-store" + if request.url.path.startswith(("/api/", "/v1/")) + else response.headers.get("Cache-Control", "no-cache") + ) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + return response + + @app.exception_handler(StudioError) + async def studio_error_handler(request: Request, exc: StudioError): + return _error_response(exc, request) + + @app.exception_handler(RequestValidationError) + async def validation_error_handler(request: Request, exc: RequestValidationError): + first = exc.errors()[0] if exc.errors() else {} + field = ".".join(str(value) for value in first.get("loc", [])[1:]) + return _error_response( + StudioError( + "REQUEST_VALIDATION_FAILED", + str(first.get("msg") or "请求参数无效"), + status_code=422, + field=field or None, + ), + request, + ) + + @app.get("/") + async def index(): + path = static_root / "index.html" + response = FileResponse(path, media_type="text/html") + if security_enabled: + response.set_cookie( + "agentkit_studio_session", + session_secret, + httponly=True, + samesite="strict", + secure=False, + path="/", + ) + return response + + @app.get("/favicon.ico") + async def favicon(): + return Response(status_code=204) + + async def runtime_model_catalog(): + api_base = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") + current_model = os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME") + try: + api_key = studio.credentials.resolve("env://OPENAI_API_KEY") + except StudioError: + api_key = None + return await studio.catalog.discover_provider_models( + api_base=api_base, + api_key=api_key, + current_model=current_model, + ) + + @app.get("/v1/models") + async def openai_models(): + models, source = await runtime_model_catalog() + return { + "object": "list", + "data": [dict(item.contract.get("metadata") or {"id": item.name}) for item in models], + "current": os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME"), + "source": source, + } + + @app.post("/v1/responses") + async def openai_responses(payload: dict[str, Any]): + metadata = payload.get("metadata") + metadata = metadata if isinstance(metadata, dict) else {} + agent_id = str(metadata.get("agent_id") or metadata.get("agentId") or "") or None + requested_approval_mode = ( + str(metadata.get("approval_mode") or metadata.get("approvalMode") or "").strip().lower() + ) + if requested_approval_mode and requested_approval_mode not in {"ask", "risk", "full"}: + raise StudioError( + "APPROVAL_MODE_INVALID", + "批准模式必须是 ask、risk 或 full", + status_code=422, + field="metadata.approval_mode", + ) + collaboration_mode = ( + str(metadata.get("collaboration_mode") or metadata.get("collaborationMode") or "") + .strip() + .lower() + ) + if collaboration_mode and collaboration_mode not in {"default", "plan"}: + raise StudioError( + "COLLABORATION_MODE_INVALID", + "协作模式必须是 default 或 plan", + status_code=422, + field="metadata.collaboration_mode", + ) + goal_objective = str( + metadata.get("goal_objective") or metadata.get("goalObjective") or "" + ).strip() + session_id = _responses_session_id(payload, bridge=shared_web) + response_id = str( + metadata.get("invocation_id") + or metadata.get("invocationId") + or f"resp_{secrets.token_hex(12)}" + ) + bridge_payload: dict[str, Any] = { + "AgentId": shared_web.resolve_agent_id(agent_id), + "SessionId": session_id or f"ses_{secrets.token_hex(12)}", + "InvocationId": response_id, + "Model": str(payload.get("model") or ""), + "ResponsesInput": _responses_input(payload.get("input")), + "ApprovalMode": requested_approval_mode, + "CollaborationMode": collaboration_mode, + "GoalObjective": goal_objective, + } + bridge_payload["Model"] = shared_web.select_model( + bridge_payload["AgentId"], + bridge_payload["Model"], + ) + if bool(payload.get("stream")): + return StreamingResponse( + shared_web.stream_run(bridge_payload), + media_type="text/event-stream", + headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}, + ) + return await shared_web.invoke_response(bridge_payload) + + @app.post("/v1/responses/{response_id}/cancel") + async def cancel_openai_response(response_id: str): + result = shared_web.cancel_run(response_id) + return { + "id": response_id, + "object": "response", + "status": "cancelled" if result["Cancelled"] else "not_found", + } + + @app.post("/v1/responses/{response_id}:pause", status_code=202) + async def pause_openai_response(response_id: str): + return await shared_web.pause_run(response_id) + + @app.post("/v1/responses/{response_id}:resume", status_code=202) + async def resume_openai_response(response_id: str): + return await shared_web.resume_run(response_id) + + @app.post("/agentengine/api/v1/{action}") + async def shared_chat_action( + action: str, + request: Request, + payload: dict[str, Any], + ): + try: + cookie_agent_id = request.cookies.get("agentkit_studio_chat_agent") + requested_agent_id = str(payload.get("AgentId") or cookie_agent_id or "") + if action == "GetAgentUiBootstrap": + data = shared_web.bootstrap(shared_web.resolve_agent_id(requested_agent_id or None)) + elif action == "ListAgentModels": + data = shared_web.list_models( + shared_web.resolve_agent_id(requested_agent_id or None) + ) + elif action == "ListSessions": + data = shared_web.list_sessions( + shared_web.resolve_agent_id(requested_agent_id or None), + page=int(payload.get("Page") or 1), + page_size=int(payload.get("PageSize") or 30), + ) + elif action == "CreateSession": + data = shared_web.create_session( + shared_web.resolve_agent_id(requested_agent_id or None) + ) + elif action == "GetSession": + data = shared_web.get_session(str(payload.get("SessionId") or "")) + elif action == "DeleteSession": + data = shared_web.delete_session(str(payload.get("SessionId") or "")) + elif action == "ListSessionMessages": + data = shared_web.list_messages( + str(payload.get("SessionId") or ""), + after_seq_id=_optional_int(payload.get("AfterSeqId")), + before_seq_id=_optional_int(payload.get("BeforeSeqId")), + limit=int(payload.get("Limit") or 50), + ) + elif action == "ListSessionEvents": + data = shared_web.list_session_events(str(payload.get("SessionId") or "")) + elif action == "RunAgent": + return StreamingResponse( + shared_web.stream_run(payload), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-store", + "X-Accel-Buffering": "no", + }, + ) + elif action == "CancelRun": + data = shared_web.cancel_run(str(payload.get("InvocationId") or "")) + elif action in { + "GetResponseFeedback", + "UpsertResponseFeedback", + "DeleteResponseFeedback", + }: + data = {"Feedback": None} if action == "GetResponseFeedback" else {} + elif action == "ListSessionCheckpoints": + data = {"Checkpoints": []} + elif action == "ListToolReceipts": + data = {"ToolReceipts": []} + else: + return JSONResponse( + status_code=404, + content={ + "Code": 404, + "Message": f"Studio 尚未实现共享 Web 动作:{action}", + "Data": {}, + }, + ) + return {"Code": 0, "Message": "OK", "Data": data} + except StudioError as exc: + return JSONResponse( + status_code=exc.status_code, + content={ + "Code": exc.status_code, + "Message": exc.message, + "Data": {"errorCode": exc.code}, + }, + ) + + @app.get("/agentengine/api/v1/SubscribeRunEvents") + async def shared_chat_subscribe_run_events(): + async def completed_stream(): + yield "event: done\ndata: [DONE]\n\n" + + return StreamingResponse( + completed_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}, + ) + + @app.get("/api/v1/system/health") + async def health(): + return { + "status": "ok", + "version": "1.0.0", + "workspaceReady": studio.workspace.root.is_dir(), + } + + @app.post("/api/v1/system/session") + async def exchange_session(payload: SessionExchangeRequest, response: Response): + if security_enabled and not hmac.compare_digest(payload.token, session_secret): + raise StudioError( + "LOCAL_SESSION_INVALID", + "Studio 启动会话无效", + status_code=401, + ) + response.set_cookie( + "agentkit_studio_session", + session_secret, + httponly=True, + samesite="strict", + secure=False, + path="/", + ) + return {"csrfToken": csrf_secret} + + @app.get("/api/v1/system/bootstrap") + async def bootstrap(): + return { + "serverVersion": "1.0.0", + "apiVersion": "v1", + "sessionTokenRequired": security_enabled, + "csrfToken": csrf_secret, + "workspace": { + "name": studio.workspace.root.name, + "path": str(studio.workspace.root), + }, + "features": { + "build": True, + "run": True, + "runtimeRegistry": True, + "runtimeTypes": ["codex", "adk", "langgraph"], + "evaluation": True, + "deployment": True, + "cloudRebuild": False, + "reactChat": True, + }, + "runtimes": studio.runtime_catalog(), + } + + @app.get("/api/v1/system/settings") + async def get_settings(): + return studio.get_settings() + + @app.put("/api/v1/system/settings") + async def update_settings(payload: dict[str, Any]): + return studio.update_settings(payload) + + @app.post("/api/v1/workspaces:open") + async def open_workspace(payload: WorkspaceOpenRequest): + requested = Path(payload.path).expanduser().resolve() + if requested != studio.workspace.root: + raise StudioError( + "WORKSPACE_PATH_FORBIDDEN", + "当前 Daemon 不允许切换到启动 root 之外的工作区", + status_code=403, + ) + return { + "name": studio.workspace.root.name, + "path": str(studio.workspace.root), + } + + @app.get("/api/v1/codex/manifest") + async def get_codex_manifest(): + return studio.codex_manifest_state() + + @app.put("/api/v1/codex/manifest") + async def put_codex_manifest(payload: CodexAgentManifest): + return studio.save_codex_manifest(payload) + + @app.post("/api/v1/codex/manifest:validate") + async def validate_codex_manifest(_payload: CodexAgentManifest): + return {"valid": True, "diagnostics": []} + + @app.post("/api/v1/codex/builds", status_code=202) + async def create_codex_build( + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + ): + return studio.submit_codex_build(idempotency_key=_require_idempotency_key(idempotency_key)) + + @app.get("/api/v1/codex/builds") + async def list_codex_builds(): + return {"items": studio.codex_builds.list()} + + @app.get("/api/v1/codex/builds/{build_id}") + async def get_codex_build(build_id: str): + return studio.codex_builds.get(build_id) + + @app.post("/api/v1/codex/builds/{build_id}/runs", status_code=202) + async def create_codex_run( + build_id: str, + payload: RunRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + ): + if payload.environment != "local": + raise StudioError( + "RUN_ENVIRONMENT_UNSUPPORTED", + "Codex Studio 只支持只读本地运行", + status_code=422, + ) + return studio.submit_codex_run( + build_id, + payload.input.content, + session_id=payload.session_id, + model=payload.model, + idempotency_key=_require_idempotency_key(idempotency_key), + ) + + @app.post("/api/v1/builds/{build_id}/run:stream") + async def stream_run( + build_id: str, + payload: RunRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + ): + key = _require_idempotency_key(idempotency_key) + if payload.environment != "local": + raise StudioError( + "RUN_ENVIRONMENT_UNSUPPORTED", + "Codex Studio 只支持只读本地运行", + status_code=422, + ) + + queue: asyncio.Queue = asyncio.Queue() + + def observe(event): + queue.put_nowait(event) + + # The Operation owns the runtime task. The SSE response is only one + # observer, so refreshing or switching chats cannot cancel the Run. + operation = studio.submit_studio_run( + build_id, + payload.input.content, + session_id=payload.session_id, + model=payload.model, + sandbox=payload.sandbox, + idempotency_key=key, + on_event=observe, + ) + + async def render(): + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=0.1) + except TimeoutError: + event = None + if event is not None: + data = json.dumps( + event.data, + ensure_ascii=False, + separators=(",", ":"), + ) + yield f"id: {event.id}\nevent: {event.type}\ndata: {data}\n\n" + current = studio.operations.get(operation.id) + if ( + current.status + in { + OperationStatus.SUCCEEDED, + OperationStatus.FAILED, + OperationStatus.CANCELLED, + OperationStatus.INTERRUPTED, + } + and queue.empty() + ): + if current.status == OperationStatus.FAILED: + data = json.dumps(current.error or {}, ensure_ascii=False) + yield f"event: run.failed\ndata: {data}\n\n" + break + + return StreamingResponse( + render(), + media_type="text/event-stream", + headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}, + ) + + @app.post("/api/v1/agents", status_code=201) + async def create_agent(payload: CreateAgentRequest): + return studio.create_studio_agent( + agent_id=payload.id, + name=payload.name, + description=payload.description, + template=payload.template, + spec=payload.spec, + runtime=payload.runtime, + ) + + @app.post("/api/v1/assets/agent-avatars", status_code=201) + async def upload_agent_avatar(request: Request): + declared_size = request.headers.get("Content-Length") + if ( + declared_size + and declared_size.isdigit() + and int(declared_size) > studio.avatar_assets.MAX_BYTES + ): + raise StudioError( + "AGENT_AVATAR_TOO_LARGE", + "头像文件不能超过 2 MiB", + status_code=413, + field="file", + details={"maxBytes": studio.avatar_assets.MAX_BYTES}, + ) + return studio.avatar_assets.store( + await request.body(), + content_type=request.headers.get("Content-Type", ""), + ) + + @app.get("/api/v1/assets/agent-avatars/{asset_name}") + async def get_agent_avatar(asset_name: str): + path, media_type = studio.avatar_assets.get(asset_name) + return FileResponse( + path, + media_type=media_type, + headers={"Cache-Control": "private, max-age=31536000, immutable"}, + ) + + @app.get("/api/v1/agent-templates") + async def agent_templates(): + return {"items": studio.list_agent_templates()} + + @app.post("/api/v1/agent-templates/{template_id}:compose") + async def compose_agent_template( + template_id: str, + payload: AgentTemplateComposeRequest, + ): + return studio.compose_agent_template(template_id, payload) + + @app.post("/api/v1/authoring/quick", status_code=201) + async def quick_author_agent(payload: QuickAuthoringRequest): + return studio.create_authored_agent( + name=payload.name, + slug=payload.slug, + runtime_type=payload.runtime_type, + template=payload.template, + description=payload.description, + spec=payload.spec, + ) + + @app.post("/api/v1/authoring/conversations:compose") + async def compose_authoring_conversation(payload: ConversationAuthoringRequest): + return await studio.compose_agent_conversation( + messages=[item.model_dump(mode="json") for item in payload.messages], + model_profile_id=payload.model_profile_id, + ) + + @app.post("/api/v1/authoring/imports:inspect") + async def inspect_agent_import(file: UploadFile = File(...)): + content = await file.read(100 * 1024 * 1024 + 1) + return studio.inspect_agent_import( + content, + filename=file.filename or "agent.yaml", + ) + + @app.post( + "/api/v1/authoring/imports/{inspection_token}:commit", + status_code=201, + ) + async def commit_agent_import( + inspection_token: str, + payload: AuthoringCommitRequest, + ): + return studio.commit_agent_import( + inspection_token, + name=payload.name, + slug=payload.slug, + ) + + @app.post("/api/v1/authoring/projects:inspect") + async def inspect_agent_project(payload: ProjectInspectRequest): + return studio.inspect_agent_project(payload.path) + + @app.post( + "/api/v1/authoring/projects/{inspection_token}:commit", + status_code=201, + ) + async def commit_agent_project( + inspection_token: str, + payload: AuthoringCommitRequest, + ): + return studio.commit_agent_project( + inspection_token, + name=payload.name, + slug=payload.slug, + model_profile_id=payload.model_profile_id, + ) + + @app.get("/api/v1/agents") + async def list_agents( + limit: int = Query(default=50, ge=1, le=200), + query: str = "", + ): + return { + "items": studio.list_agents(query=query, limit=limit), + "nextCursor": None, + } + + @app.get("/api/v1/agents/{agent_id}") + async def get_agent(agent_id: str): + return studio.agent_detail(agent_id) + + @app.get("/api/v1/agents/{agent_id}/models") + async def get_agent_models(agent_id: str): + return shared_web.list_models(shared_web.resolve_agent_id(agent_id)) + + @app.put("/api/v1/agents/{agent_id}") + async def update_agent( + agent_id: str, + spec: AgentSpec, + if_match: str | None = Header(default=None, alias="If-Match"), + ): + if not if_match: + raise StudioError( + "AGENT_REVISION_REQUIRED", + "更新 Agent 必须提供 If-Match revision", + status_code=428, + ) + try: + revision = int(if_match.strip().strip('"')) + except ValueError as exc: + raise StudioError( + "AGENT_REVISION_INVALID", + "If-Match 必须是整数 revision", + status_code=400, + ) from exc + return studio.update_studio_agent( + agent_id, + spec, + expected_revision=revision, + ) + + @app.put("/api/v1/agents/{agent_id}/bindings") + async def update_agent_bindings( + agent_id: str, + bindings: AgentBindings, + if_match: str | None = Header(default=None, alias="If-Match"), + ): + revision = _parse_revision(if_match) + return studio.update_studio_agent_bindings( + agent_id, + bindings, + expected_revision=revision, + ) + + @app.put("/api/v1/agents/{agent_id}/appearance") + async def update_agent_appearance( + agent_id: str, + appearance: AgentAppearance, + if_match: str | None = Header(default=None, alias="If-Match"), + ): + return studio.update_studio_agent_appearance( + agent_id, + appearance, + expected_revision=_parse_revision(if_match), + ) + + @app.delete("/api/v1/agents/{agent_id}", status_code=204) + async def delete_agent(agent_id: str, purge: bool = False): + studio.delete_studio_agent(agent_id, purge=purge) + return Response(status_code=204) + + @app.post("/api/v1/agents/{agent_id}/validations") + async def validate_agent(agent_id: str, payload: ValidationRequest): + return studio.validate_studio_agent( + agent_id, + revision=payload.revision, + level=payload.level, + ) + + @app.post("/api/v1/agents/{agent_id}/builds", status_code=202) + async def create_build( + agent_id: str, + payload: BuildRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + ): + return studio.submit_studio_build( + agent_id, + revision=payload.revision, + idempotency_key=_require_idempotency_key(idempotency_key), + ) + + @app.get("/api/v1/builds/{build_id}") + async def get_build(build_id: str): + return studio.build_view(build_id) + + @app.get("/api/v1/builds/{build_id}/manifest") + async def get_build_manifest(build_id: str): + try: + record = studio.codex_builds.get(build_id) + except StudioError as exc: + if exc.status_code != 404: + raise + else: + return { + "agentengine": studio.codex_manifests.load(record.agent_name).manifest, + "runtimeLock": record.runtime_lock, + "manifestSha256": record.manifest_sha256, + } + build = studio.builds.get(build_id) + if not build.artifact_path: + raise StudioError("BUILD_NOT_READY", "Build 尚未生成制品", status_code=409) + archive = studio.workspace.resolve(build.artifact_path) + manifest = archive.parent / "agent-bundle" / "manifest.json" + return json.loads(manifest.read_text(encoding="utf-8")) + + @app.post("/api/v1/builds/{build_id}/runs", status_code=202) + async def create_run( + build_id: str, + payload: RunRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + ): + if payload.environment != "local": + raise StudioError( + "RUN_ENVIRONMENT_UNSUPPORTED", + "一期 Local Run 只支持 environment=local", + status_code=422, + ) + return studio.submit_studio_run( + build_id, + payload.input.content, + session_id=payload.session_id, + model=payload.model, + idempotency_key=_require_idempotency_key(idempotency_key), + ) + + @app.get("/api/v1/runs/{run_id}") + async def get_run(run_id: str): + return studio.event_store.get(run_id) + + @app.post("/api/v1/runs/{run_id}:cancel", status_code=202) + async def cancel_run(run_id: str): + return await studio.run_service.cancel_run(run_id) + + @app.post("/api/v1/runs/{run_id}:pause", status_code=202) + async def pause_run(run_id: str): + return await studio.run_service.pause_run(run_id) + + @app.post("/api/v1/runs/{run_id}:resume", status_code=202) + async def resume_run(run_id: str): + return await studio.run_service.resume_run(run_id) + + @app.post("/api/v1/runs/{run_id}/interactions/{interaction_id}:submit") + async def submit_run_interaction( + run_id: str, + interaction_id: str, + payload: InteractionSubmitRequest, + ): + return await studio.run_service.submit_interaction( + run_id, + interaction_id, + name=payload.name, + data=payload.data, + ) + + @app.delete("/api/v1/sessions/{session_id}", status_code=204) + async def delete_studio_session(session_id: str): + studio.delete_session(session_id) + return Response(status_code=204) + + @app.get("/api/v1/runs") + async def list_runs(session_id: str | None = Query(default=None, alias="sessionId")): + return {"items": studio.event_store.list_runs(session_id=session_id)} + + @app.get("/api/v1/runs/{run_id}/events") + async def run_events( + run_id: str, + request: Request, + after: int = Query(default=0, ge=0), + ): + last = request.headers.get("Last-Event-ID") + cursor = int(last) if last and last.isdigit() else after + events = studio.event_store.events(run_id, after=cursor) + return _sse(events) + + @app.get("/api/v1/traces/overview") + async def trace_overview( + range_name: Literal["24h", "7d"] = Query(default="24h", alias="range"), + agent_id: str | None = Query(default=None, alias="agentId"), + status: str | None = Query(default=None), + ): + return studio.event_store.trace_overview( + range_name=range_name, + agent_id=agent_id, + status=status, + ) + + @app.get("/api/v1/traces") + async def list_traces( + agent_id: str | None = Query(default=None, alias="agentId"), + status: str | None = Query(default=None), + query: str = Query(default=""), + limit: int = Query(default=50, ge=1, le=1000), + cursor: str | None = Query(default=None), + sort: Literal["startedAt:desc", "startedAt:asc"] = "startedAt:desc", + ): + return studio.event_store.list_traces_page( + agent_id=agent_id, + status=status, + query=query, + limit=limit, + cursor=cursor, + sort=sort, + ) + + @app.get("/api/v1/traces/{trace_id}") + async def get_trace(trace_id: str): + return studio.event_store.trace(trace_id) + + @app.get("/api/v1/traces/{trace_id}/otlp") + async def get_trace_otlp(trace_id: str): + return studio.event_store.trace_otlp(trace_id) + + @app.post("/api/v1/builds/{build_id}/evaluations", status_code=202) + async def create_evaluation( + build_id: str, + payload: EvaluationRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + ): + return studio.submit_evaluation( + build_id, + payload.suite_refs, + fail_fast=payload.fail_fast, + idempotency_key=_require_idempotency_key(idempotency_key), + ) + + @app.post("/api/v1/evaluations", status_code=202) + async def create_public_evaluation( + payload: StudioEvaluationCreate, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + ): + return studio.submit_public_evaluation( + payload.evalset_file, + payload.target, + payload.config, + idempotency_key=_require_idempotency_key(idempotency_key), + ) + + @app.get("/api/v1/evaluations") + async def list_public_evaluations(): + return {"items": studio.list_public_evaluations()} + + @app.get("/api/v1/evaluations/{evaluation_id}") + async def get_evaluation(evaluation_id: str): + report_path = studio.evaluation_storage.report_path(evaluation_id) + if report_path.is_file(): + return studio.get_public_evaluation(evaluation_id) + return studio.evaluations.get(evaluation_id) + + @app.get("/api/v1/evaluations/{evaluation_id}/cases/{case_id}") + async def get_public_evaluation_case(evaluation_id: str, case_id: str): + report = studio.get_public_evaluation(evaluation_id) + for case_run in report.case_runs: + if case_run.case_id == case_id: + return case_run + raise StudioError( + "EVALUATION_CASE_NOT_FOUND", + "Evaluation Case 不存在", + status_code=404, + details={"id": case_id, "evaluationId": evaluation_id}, + ) + + @app.post("/api/v1/builds/{build_id}/deployments", status_code=202) + async def create_deployment( + build_id: str, + payload: DeploymentRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + ): + return studio.submit_deployment( + build_id, + payload, + idempotency_key=_require_idempotency_key(idempotency_key), + ) + + @app.get("/api/v1/deployments/{deployment_id}") + async def get_deployment(deployment_id: str): + return studio.cloud.get(deployment_id) + + @app.post("/api/v1/deployments/{deployment_id}:rollback", status_code=202) + async def rollback_deployment( + deployment_id: str, + payload: RollbackRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + ): + return studio.submit_rollback( + deployment_id, + target_build_id=payload.target_build_id, + idempotency_key=_require_idempotency_key(idempotency_key), + ) + + @app.get("/api/v1/operations/{operation_id}") + async def get_operation(operation_id: str): + return studio.operations.get(operation_id) + + @app.post("/api/v1/operations/{operation_id}:cancel") + async def cancel_operation(operation_id: str): + return studio.operations.cancel(operation_id) + + @app.get("/api/v1/operations/{operation_id}/events") + async def operation_events( + operation_id: str, + request: Request, + after: int = Query(default=0, ge=0), + ): + last = request.headers.get("Last-Event-ID") + cursor = int(last) if last and last.isdigit() else after + return _sse(studio.operations.events(operation_id, after=cursor)) + + register_catalog_routes( + app, + studio, + runtime_model_catalog=runtime_model_catalog, + ) + + return app diff --git a/ksadk/studio/api_catalog_routes.py b/ksadk/studio/api_catalog_routes.py new file mode 100644 index 00000000..c094cd09 --- /dev/null +++ b/ksadk/studio/api_catalog_routes.py @@ -0,0 +1,324 @@ +"""Catalog, credential and capability routes for Studio.""" + +from __future__ import annotations + +from typing import Awaitable, Callable, Literal + +from fastapi import FastAPI, File, Query, UploadFile +from jsonschema import Draft202012Validator # type: ignore[import-untyped] +from jsonschema.exceptions import SchemaError # type: ignore[import-untyped] + +from ksadk.studio.api_contracts import ( + CredentialPutRequest, + MCPResourceCreateRequest, + ModelEndpointProbeRequest, + ModelProfileCreateRequest, + PythonToolCommitRequest, + SecretReferenceCheckRequest, + SkillDiscoveryCommitRequest, + SkillDiscoveryRequest, + ToolPolicyPreviewRequest, + ToolResourceCreateRequest, + ToolSchemaValidationRequest, +) +from ksadk.studio.contracts import MCPServerRef +from ksadk.studio.errors import StudioError +from ksadk.studio.model_profile_service import probe_model_endpoint +from ksadk.studio.service import StudioService + +ModelCatalogLoader = Callable[[], Awaitable[tuple[list, str]]] + + +def register_catalog_routes( + app: FastAPI, + studio: StudioService, + *, + runtime_model_catalog: ModelCatalogLoader, +) -> None: + """Register resource routes without expanding the main HTTP composition.""" + + @app.get("/api/v1/capabilities") + async def capabilities(kind: str | None = None, query: str = ""): + return {"items": studio.list_capabilities(kind=kind, query=query)} + + @app.get("/api/v1/catalog/resources") + async def catalog_resources( + kind: Literal["model", "tool", "mcp", "skill"] | None = None, + query: str = "", + source: Literal["builtin", "provider", "local", "market"] | None = None, + status: str | None = None, + installed: bool | None = None, + limit: int = Query(default=50, ge=1, le=200), + cursor: str | None = None, + sort: Literal["default", "displayName:asc", "displayName:desc"] = "default", + ): + if kind == "model": + await runtime_model_catalog() + + def effective_status(resource) -> str: + if resource.kind != "model": + return resource.status + references = list(resource.required_secret_refs or []) + reference = references[0] if references else resource.contract.get("credentialRef") + if not reference: + return resource.status + return ( + "ready" + if studio.credentials.status(str(reference))["configured"] + else "missing-secret" + ) + + return studio.catalog.list_page( + kind=kind, + query=query, + source=source, + status=status, + installed=installed, + limit=limit, + cursor=cursor, + sort=sort, + status_resolver=effective_status, + ) + + @app.get("/api/v1/catalog/models") + async def catalog_models(): + items, source = await runtime_model_catalog() + return {"items": items, "source": source, "nextCursor": None} + + @app.get("/api/v1/catalog/runtimes") + async def catalog_runtimes(): + return {"items": studio.runtime_catalog(), "nextCursor": None} + + @app.get("/api/v1/catalog/resources/{resource_id}") + async def get_catalog_resource(resource_id: str): + return studio.catalog.get(resource_id) + + @app.post("/api/v1/catalog/model-profiles", status_code=201) + async def create_model_profile(payload: ModelProfileCreateRequest): + return studio.catalog.create_model_profile( + name=payload.name, + display_name=payload.display_name, + version=payload.version, + description=payload.description, + spec=payload.spec, + ) + + @app.post("/api/v1/catalog/mcp-servers", status_code=201) + async def create_mcp_resource(payload: MCPResourceCreateRequest): + return studio.catalog.create_mcp_server( + display_name=payload.display_name, + description=payload.description, + server=payload.server, + ) + + @app.post("/api/v1/catalog/mcp-servers/{resource_id}:probe") + async def probe_mcp_resource( + resource_id: str, + timeout_seconds: int = Query(default=10, alias="timeoutSeconds", ge=1, le=60), + ): + descriptor = studio.catalog.get(resource_id) + if descriptor.kind != "mcp": + raise StudioError( + "RESOURCE_KIND_INVALID", + "该 Resource 不是 MCP Server", + status_code=422, + details={"resourceId": resource_id}, + ) + server_payload = { + key: value for key, value in descriptor.contract.items() if key != "discoveredTools" + } + server = MCPServerRef.model_validate(server_payload) + try: + result = await studio.mcp_runtime.probe(server, timeout_seconds=timeout_seconds) + except StudioError as exc: + detail = str((exc.details or {}).get("detail") or exc.message) + studio.catalog.mark_probe_failed(resource_id, code=exc.code, detail=detail) + raise + return studio.catalog.save_probe(resource_id, result=result) + + @app.delete("/api/v1/catalog/resources/{resource_id}", status_code=204) + async def delete_catalog_resource(resource_id: str): + descriptor = studio.catalog.get(resource_id) + if descriptor.kind not in {"mcp", "tool", "model", "skill"}: + raise StudioError( + "RESOURCE_KIND_INVALID", + "只支持删除 MCP / Tool / Model / Skill 资源", + status_code=422, + details={"kind": descriptor.kind}, + ) + if descriptor.kind == "model" and descriptor.source != "local": + raise StudioError( + "RESOURCE_KIND_INVALID", + "内置或上游发现的模型不可删除;仅支持删除手动添加的模型", + status_code=422, + details={"kind": descriptor.kind, "source": descriptor.source}, + ) + studio.catalog.delete_resource(resource_id) + return None + + @app.post("/api/v1/catalog/tools", status_code=201) + async def create_tool_resource(payload: ToolResourceCreateRequest): + return studio.catalog.create_tool( + display_name=payload.display_name, + category=payload.category, + contract=payload.contract, + ) + + @app.post("/api/v1/catalog/python-tools:inspect") + async def inspect_python_tool(file: UploadFile = File(...)): + content = await file.read(1024 * 1024 + 1) + return studio.catalog.inspect_python_tool( + content, + filename=file.filename or "tool.py", + ) + + @app.post( + "/api/v1/catalog/python-tools/{inspection_token}:commit", + status_code=201, + ) + async def commit_python_tool( + inspection_token: str, + payload: PythonToolCommitRequest, + ): + return studio.catalog.commit_python_tool( + inspection_token, + display_name=payload.display_name, + name=payload.name, + callable_name=payload.callable_name, + description=payload.description, + ) + + @app.post("/api/v1/catalog/skills:import", status_code=201) + async def import_skill(file: UploadFile = File(...)): + content = await file.read(50 * 1024 * 1024 + 1) + return studio.catalog.import_skill_zip(content, filename=file.filename or "skill.zip") + + @app.post("/api/v1/catalog/skills:discover") + async def discover_skills(payload: SkillDiscoveryRequest): + return studio.catalog.discover_skills(scan_paths=payload.scan_paths or None) + + @app.get( + "/api/v1/catalog/skills/discoveries/{inspection_token}/candidates/{candidate_id}/files" + ) + async def preview_discovered_skill( + inspection_token: str, + candidate_id: str, + path: str | None = Query(default=None, max_length=1024), + ): + return studio.catalog.preview_discovered_skill( + inspection_token, + candidate_id, + path=path, + ) + + @app.get("/api/v1/catalog/skills/{resource_id}/files") + async def preview_installed_skill( + resource_id: str, + path: str | None = Query(default=None, max_length=1024), + ): + return studio.catalog.preview_installed_skill(resource_id, path=path) + + @app.post( + "/api/v1/catalog/skills/discoveries/{inspection_token}:commit", + status_code=201, + ) + async def commit_discovered_skill( + inspection_token: str, + payload: SkillDiscoveryCommitRequest, + ): + return studio.catalog.commit_discovered_skill( + inspection_token, + payload.candidate_id, + overwrite=payload.overwrite, + ) + + @app.post("/api/v1/tool-schemas:validate") + async def validate_tool_schema(payload: ToolSchemaValidationRequest): + try: + Draft202012Validator.check_schema(payload.schema_definition) + except SchemaError as exc: + return { + "valid": False, + "diagnostics": [ + { + "code": "TOOL_SCHEMA_INVALID", + "message": exc.message, + "path": list(exc.path), + } + ], + } + errors = sorted( + Draft202012Validator(payload.schema_definition).iter_errors(payload.sample), + key=lambda error: list(error.absolute_path), + ) + return { + "valid": not errors, + "diagnostics": [ + { + "code": "TOOL_SAMPLE_INVALID", + "message": error.message, + "path": list(error.absolute_path), + } + for error in errors + ], + } + + @app.post("/api/v1/tool-policies:preview") + async def preview_tool_policy(payload: ToolPolicyPreviewRequest): + tools, permissions = studio.catalog.policy_preview(payload.bindings) + return {"tools": tools, "allowedPermissions": permissions} + + @app.post("/api/v1/mcp-servers:probe") + async def probe_mcp_server( + payload: MCPServerRef, + timeout_seconds: int = Query(default=10, alias="timeoutSeconds", ge=1, le=60), + ): + return await studio.mcp_runtime.probe(payload, timeout_seconds=timeout_seconds) + + @app.post("/api/v1/secret-references:check") + async def check_secret_reference(payload: SecretReferenceCheckRequest): + status = studio.credentials.status(payload.ref) + return { + "exists": status["configured"], + "scheme": payload.ref.partition("://")[0], + "source": status["source"], + } + + @app.get("/api/v1/credentials/{credential_name}") + async def get_credential_status(credential_name: str): + return studio.credentials.status(f"env://{credential_name}") + + @app.put("/api/v1/credentials/{credential_name}") + async def put_credential(credential_name: str, payload: CredentialPutRequest): + return studio.credentials.put_session( + credential_name, + payload.value.get_secret_value(), + ) + + @app.delete("/api/v1/credentials/{credential_name}") + async def delete_credential(credential_name: str): + return studio.credentials.delete_session(credential_name) + + @app.post("/api/v1/model-profiles/{resource_id}:test") + async def test_model_profile(resource_id: str): + return await studio.test_model_profile(resource_id) + + @app.post("/api/v1/model-endpoints:probe") + async def probe_model_endpoint_route(payload: ModelEndpointProbeRequest): + credential: str | None = None + if payload.api_key is not None: + credential = payload.api_key.get_secret_value() + elif payload.credential_ref: + try: + credential = studio.credentials.resolve(payload.credential_ref) + except Exception: + credential = None # 未配置凭证时降级为匿名探测 + guard = getattr(studio.model_client, "network_guard", None) + return await probe_model_endpoint( + url=payload.url, + credential=credential, + network_guard=guard, + ) + + +__all__ = ["register_catalog_routes"] diff --git a/ksadk/studio/api_contracts.py b/ksadk/studio/api_contracts.py new file mode 100644 index 00000000..67e0dde0 --- /dev/null +++ b/ksadk/studio/api_contracts.py @@ -0,0 +1,177 @@ +"""HTTP request contracts used by the AgentKit Studio control plane.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field, SecretStr + +from ksadk.evaluation import EvaluationConfig as PublicEvaluationConfig +from ksadk.evaluation import TargetRef +from ksadk.studio.contracts import ( + AgentBindings, + AgentSpec, + ContractModel, + MCPServerRef, + ModelSpec, + RuntimeRef, + ToolContract, +) + + +class SessionExchangeRequest(ContractModel): + token: str = Field(min_length=16, max_length=512) + + +class WorkspaceOpenRequest(ContractModel): + path: str + create: bool = False + + +class CreateAgentRequest(ContractModel): + id: str = Field(pattern=r"^[a-z][a-z0-9-]{2,62}$") + name: str = Field(min_length=1, max_length=128) + description: str = Field(default="", max_length=1024) + template: str = "blank" + runtime: RuntimeRef | None = None + spec: AgentSpec | None = None + + +class ValidationRequest(ContractModel): + revision: int = Field(ge=1) + level: Literal["schema", "build", "release"] = "build" + + +class BuildRequest(ContractModel): + revision: int = Field(ge=1) + run_evaluation: bool = False + evaluation_suite_refs: list[str] = Field(default_factory=list) + + +class MessageInput(ContractModel): + role: str = "user" + content: str = Field(min_length=1, max_length=1_000_000) + + +class QuickAuthoringRequest(ContractModel): + name: str = Field(min_length=1, max_length=128) + slug: str | None = Field(default=None, min_length=1, max_length=63) + runtime_type: Literal["codex", "adk", "langgraph"] + template: Literal["blank", "research"] = "blank" + description: str = Field(default="", max_length=1024) + spec: AgentSpec | None = None + + +class AuthoringConversationMessage(ContractModel): + role: Literal["user", "assistant"] + content: str = Field(min_length=1, max_length=32768) + + +class ConversationAuthoringRequest(ContractModel): + messages: list[AuthoringConversationMessage] = Field(min_length=1, max_length=100) + model_profile_id: str = Field(min_length=3, max_length=256) + + +class AuthoringCommitRequest(ContractModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + slug: str | None = Field(default=None, min_length=1, max_length=63) + model_profile_id: str | None = Field(default=None, min_length=3, max_length=256) + + +class ProjectInspectRequest(ContractModel): + path: str = Field(min_length=1, max_length=4096) + + +class RunRequest(ContractModel): + session_id: str | None = None + model: str | None = Field(default=None, min_length=1, max_length=256) + input: MessageInput + environment: str = "local" + stream: bool = True + sandbox: str | None = None + + +class InteractionSubmitRequest(ContractModel): + name: str = Field(min_length=1, max_length=64) + data: dict[str, Any] = Field(default_factory=dict) + + +class EvaluationRequest(ContractModel): + suite_refs: list[str] = Field(min_length=1) + concurrency: int = Field(default=1, ge=1, le=4) + fail_fast: bool = False + + +class StudioEvaluationCreate(ContractModel): + """Request for the shared CLI/Studio evaluation executor.""" + + evalset_file: str = Field(min_length=1, max_length=4096) + target: TargetRef + config: PublicEvaluationConfig = Field(default_factory=PublicEvaluationConfig) + + +class SecretReferenceCheckRequest(ContractModel): + ref: str + + +class CredentialPutRequest(ContractModel): + value: SecretStr = Field(min_length=1, max_length=16_384) + persistence: Literal["session"] = "session" + + +class RollbackRequest(ContractModel): + target_build_id: str + + +class ModelProfileCreateRequest(ContractModel): + name: str = Field(pattern=r"^[a-z][a-z0-9._-]{1,127}$") + display_name: str = Field(min_length=1, max_length=128) + version: str = Field(default="1.0.0", min_length=1, max_length=64) + description: str = Field(default="", max_length=4096) + spec: ModelSpec + + +class ModelEndpointProbeRequest(ContractModel): + url: str = Field(min_length=8, max_length=1024) + credential_ref: str | None = Field(default=None, max_length=512) + api_key: SecretStr | None = None + + +class MCPResourceCreateRequest(ContractModel): + display_name: str = Field(min_length=1, max_length=128) + description: str = Field(default="", max_length=4096) + server: MCPServerRef + + +class ToolResourceCreateRequest(ContractModel): + display_name: str = Field(min_length=1, max_length=128) + category: str = Field(default="custom", max_length=64) + contract: ToolContract + + +class PythonToolCommitRequest(ContractModel): + display_name: str = Field(min_length=1, max_length=128) + name: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,127}$") + callable_name: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]{0,127}$") + description: str = Field(default="", max_length=1024) + + +class ToolSchemaValidationRequest(ContractModel): + schema_definition: dict[str, Any] = Field(alias="schema") + sample: Any = None + + +class ToolPolicyPreviewRequest(ContractModel): + bindings: AgentBindings + + +class SkillDiscoveryRequest(ContractModel): + scan_paths: list[str] = Field(default_factory=list, max_length=20) + + +class SkillDiscoveryCommitRequest(ContractModel): + candidate_id: str = Field(min_length=1, max_length=128) + overwrite: bool = False + + +__all__ = [name for name in globals() if name.endswith("Request")] diff --git a/ksadk/studio/api_helpers.py b/ksadk/studio/api_helpers.py new file mode 100644 index 00000000..b1c8cd2a --- /dev/null +++ b/ksadk/studio/api_helpers.py @@ -0,0 +1,132 @@ +"""Small protocol helpers shared by Studio HTTP route composition.""" + +from __future__ import annotations + +import json +import re +from typing import Any +from urllib.parse import urlparse + +from fastapi import Request +from fastapi.responses import JSONResponse, StreamingResponse + +from ksadk.studio.errors import StudioError +from ksadk.studio.shared_web import StudioSharedWebBridge + + +def is_local_origin(origin: str, *, local_hosts: set[str]) -> bool: + parsed = urlparse(origin) + return parsed.scheme in {"http", "https"} and (parsed.hostname or "").lower() in local_hosts + + +def responses_input(value: Any) -> list[dict[str, Any]]: + if isinstance(value, str) and value.strip(): + return [ + { + "role": "user", + "content": [{"type": "input_text", "text": value.strip()}], + } + ] + if isinstance(value, list) and value: + return value + raise StudioError( + "RUN_INPUT_REQUIRED", + "Responses input 必须是非空字符串或消息数组", + status_code=422, + field="input", + ) + + +def responses_session_id( + payload: dict[str, Any], + *, + bridge: StudioSharedWebBridge, +) -> str | None: + metadata = payload.get("metadata") + if isinstance(metadata, dict): + candidate = metadata.get("session_id") or metadata.get("sessionId") + if candidate: + return str(candidate) + conversation = payload.get("conversation") + if isinstance(conversation, str) and conversation.strip(): + return conversation.strip() + if isinstance(conversation, dict) and conversation.get("id"): + return str(conversation["id"]) + previous_response_id = payload.get("previous_response_id") + if previous_response_id: + return bridge.response_session_id(str(previous_response_id)) + return None + + +def error_response(exc: StudioError, request: Request) -> JSONResponse: + request_id = getattr(request.state, "request_id", None) + return JSONResponse( + status_code=exc.status_code, + content=exc.as_dict(request_id=request_id), + headers={"X-Request-Id": request_id or ""}, + ) + + +def require_idempotency_key(value: str | None) -> str: + if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,200}", value): + raise StudioError( + "IDEMPOTENCY_KEY_REQUIRED", + "异步创建接口必须提供合法 Idempotency-Key", + status_code=400, + ) + return value + + +def parse_revision(value: str | None) -> int: + if not value: + raise StudioError( + "AGENT_REVISION_REQUIRED", + "更新 Agent 必须提供 If-Match revision", + status_code=428, + ) + try: + return int(value.strip().strip('"')) + except ValueError as exc: + raise StudioError( + "AGENT_REVISION_INVALID", + "If-Match 必须是整数 revision", + status_code=400, + ) from exc + + +def optional_int(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError) as exc: + raise StudioError( + "REQUEST_VALIDATION_FAILED", + "分页游标必须是整数", + status_code=422, + ) from exc + + +def sse(events: list[Any]) -> StreamingResponse: + def render(): + for event in events: + data = json.dumps(event.data, ensure_ascii=False, separators=(",", ":")) + yield f"id: {event.id}\nevent: {event.type}\ndata: {data}\n\n" + + return StreamingResponse( + render(), + media_type="text/event-stream", + headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}, + ) + + +__all__ = [ + "error_response", + "is_local_origin", + "optional_int", + "parse_revision", + "require_idempotency_key", + "responses_input", + "responses_session_id", + "sse", +] diff --git a/ksadk/studio/authoring.py b/ksadk/studio/authoring.py new file mode 100644 index 00000000..ab461381 --- /dev/null +++ b/ksadk/studio/authoring.py @@ -0,0 +1,519 @@ +"""Inspect-first Agent authoring workflows used by AgentKit Studio. + +Every flow returns a proposal or inspection before it mutates the canonical +Agent registry. Quick creation is the only one-step flow and still delegates +ID allocation to the server. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import re +import shutil +import stat +import zipfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Literal, cast +from urllib.parse import urlparse +from uuid import uuid4 + +import yaml # type: ignore[import-untyped] +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from ksadk.detection.detector import FrameworkDetector +from ksadk.studio.capabilities import canonical_json, sha256_digest +from ksadk.studio.codex_manifest import CodexAgentManifest +from ksadk.studio.contracts import ( + AgentDraft, + Instructions, + NetworkPolicy, + RuntimeRef, +) +from ksadk.studio.errors import StudioError +from ksadk.studio.workspace import Workspace + +_SLUG = re.compile(r"[^a-z0-9]+") +_MAX_IMPORT_BYTES = 100 * 1024 * 1024 +_MAX_IMPORT_FILES = 2000 +_SUPPORTED_RUNTIMES = frozenset({"codex", "adk", "langgraph"}) + + +class ConversationProposal(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=128) + slug: str = Field(min_length=1, max_length=63) + runtimeType: Literal["codex", "adk", "langgraph"] + description: str = Field(default="", max_length=1024) + instructions: Instructions + + +@dataclass(frozen=True) +class ImportInspection: + token: str + kind: Literal["agent-draft", "codex-manifest"] + payload: dict[str, Any] + runtime_type: str + display_name: str + source_digest: str + files: tuple[str, ...] + staging_directory: Path + manifest_directory: PurePosixPath + + +class AgentAuthoringService: + """Pure inspection and staging support; StudioService owns final commits.""" + + def __init__(self, workspace: Workspace) -> None: + self.workspace = workspace + self.root = workspace.resolve(".agentkit/authoring") + self.import_root = workspace.resolve(".agentkit/authoring/imports") + self.project_root = workspace.resolve(".agentkit/authoring/projects") + self.import_root.mkdir(parents=True, exist_ok=True) + self.project_root.mkdir(parents=True, exist_ok=True) + + @staticmethod + def normalize_slug(value: str) -> str: + normalized = _SLUG.sub("-", str(value or "").strip().lower()).strip("-") + if not normalized or not normalized[0].isalpha(): + normalized = f"agent-{normalized}" if normalized else "agent" + return normalized[:48].rstrip("-") + + def allocate_agent_id(self, slug: str) -> str: + base = self.normalize_slug(slug) + for _attempt in range(100): + candidate = f"{base}-{uuid4().hex[:12]}" + if not (self.workspace.resolve("agents") / candidate).exists(): + return candidate + raise StudioError( + "AGENT_ID_ALLOCATION_FAILED", + "无法分配唯一 Agent ID", + status_code=500, + ) + + @staticmethod + def runtime_ref(agent_id: str, runtime_type: str) -> RuntimeRef: + normalized = str(runtime_type or "").strip().lower() + if normalized not in _SUPPORTED_RUNTIMES: + raise StudioError( + "RUNTIME_NOT_SUPPORTED", + "创建方式仅支持 Codex、ADK 和 LangGraph", + status_code=422, + field="runtimeType", + details={"runtimeType": normalized}, + ) + if normalized == "codex": + return RuntimeRef(type="codex", version="0.144.4") + return RuntimeRef( + type=cast(Any, normalized), + project_path=f"agents/{agent_id}/source", + entry_point="agent.py", + agent_variable="graph" if normalized == "langgraph" else "root_agent", + ) + + def inspect_import(self, content: bytes, *, filename: str) -> dict[str, Any]: + if not content: + raise StudioError( + "AGENT_IMPORT_EMPTY", + "导入文件不能为空", + status_code=422, + ) + if len(content) > _MAX_IMPORT_BYTES: + raise StudioError( + "AGENT_IMPORT_TOO_LARGE", + "Agent 导入包不能超过 100 MiB", + status_code=413, + ) + token = f"imp_{uuid4().hex}" + staging = self.workspace.resolve(self.import_root / token) + staging.mkdir(parents=True, exist_ok=False) + try: + files, manifest_path = self._stage_import(staging, content, filename=filename) + payload = self._load_yaml_object(manifest_path) + kind, runtime_type, display_name = self._classify_import(payload) + manifest_relative = manifest_path.relative_to(staging) + record = { + "format": "agentkit.authoring-import/v1", + "token": token, + "kind": kind, + "runtimeType": runtime_type, + "displayName": display_name, + "sourceDigest": self._tree_digest(staging, exclude={"inspection.json"}), + "files": files, + "manifestPath": manifest_relative.as_posix(), + "manifestDirectory": manifest_relative.parent.as_posix(), + "payload": payload, + } + self.workspace.atomic_write_text( + staging / "inspection.json", + json.dumps(record, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + ) + return self._public_import_record(record) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + def load_import(self, token: str) -> ImportInspection: + staging = self._token_directory(self.import_root, token, prefix="imp_") + record_path = staging / "inspection.json" + try: + record = json.loads(record_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise StudioError( + "AGENT_IMPORT_INSPECTION_NOT_FOUND", + "导入检查不存在或已失效", + status_code=404, + ) from exc + expected = str(record.get("sourceDigest") or "") + actual = self._tree_digest(staging, exclude={"inspection.json"}) + if not expected or expected != actual: + raise StudioError( + "AGENT_IMPORT_CHANGED", + "导入暂存内容在确认前发生变化,请重新检查", + status_code=409, + ) + manifest_directory = PurePosixPath(str(record.get("manifestDirectory") or ".")) + return ImportInspection( + token=token, + kind=cast(Any, record["kind"]), + payload=cast(dict[str, Any], record["payload"]), + runtime_type=str(record["runtimeType"]), + display_name=str(record["displayName"]), + source_digest=expected, + files=tuple(str(item) for item in record.get("files") or []), + staging_directory=staging, + manifest_directory=manifest_directory, + ) + + def consume_import(self, token: str) -> None: + staging = self._token_directory(self.import_root, token, prefix="imp_") + shutil.rmtree(staging, ignore_errors=True) + + def source_directory(self, inspection: ImportInspection) -> Path | None: + if inspection.kind != "agent-draft": + return None + draft = AgentDraft.model_validate(inspection.payload) + runtime = draft.spec.runtime + if runtime is None or not runtime.project_path: + return None + candidates = [ + inspection.staging_directory / inspection.manifest_directory / runtime.project_path, + inspection.staging_directory / runtime.project_path, + inspection.staging_directory / inspection.manifest_directory / "source", + ] + for candidate in candidates: + resolved = candidate.resolve() + try: + resolved.relative_to(inspection.staging_directory.resolve()) + except ValueError: + continue + if resolved.is_dir(): + return resolved + return None + + def inspect_project(self, project_path: str) -> dict[str, Any]: + project = self.workspace.resolve(project_path, must_exist=True) + if not project.is_dir(): + raise StudioError( + "PROJECT_PATH_INVALID", + "项目识别路径必须是工作区内的目录", + status_code=422, + ) + detection = FrameworkDetector(str(project)).detect() + runtime_type = detection.type.value + if not detection.is_valid or runtime_type not in _SUPPORTED_RUNTIMES: + raise StudioError( + "PROJECT_RUNTIME_UNSUPPORTED", + "没有识别到可由 Studio 管理的 Codex、ADK 或 LangGraph Runtime", + status_code=422, + details={"detected": runtime_type}, + ) + token = f"prj_{uuid4().hex}" + relative = self.workspace.relative(project) + record = { + "format": "agentkit.authoring-project/v1", + "token": token, + "projectPath": relative, + "runtimeType": runtime_type, + "name": detection.name or project.name, + "entryPoint": detection.entry_point or None, + "agentVariable": detection.agent_variable or None, + "confidence": float(detection.confidence), + "evidence": { + "config": dict(detection.raw_config or {}), + "packagePath": detection.package_path, + }, + "sourceDigest": self._tree_digest(project), + "requiresConfirmation": True, + } + self.workspace.atomic_write_text( + self.project_root / f"{token}.json", + json.dumps(record, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + ) + return {**record, "inspectionToken": token} + + def load_project(self, token: str) -> dict[str, Any]: + if not re.fullmatch(r"prj_[0-9a-f]{32}", token): + raise StudioError( + "PROJECT_INSPECTION_NOT_FOUND", + "项目识别检查不存在或已失效", + status_code=404, + ) + path = self.workspace.resolve(self.project_root / f"{token}.json") + try: + record = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise StudioError( + "PROJECT_INSPECTION_NOT_FOUND", + "项目识别检查不存在或已失效", + status_code=404, + ) from exc + project = self.workspace.resolve(str(record["projectPath"]), must_exist=True) + if self._tree_digest(project) != record.get("sourceDigest"): + raise StudioError( + "PROJECT_CHANGED_AFTER_INSPECTION", + "项目在确认前发生变化,请重新识别", + status_code=409, + ) + return cast(dict[str, Any], record) + + def consume_project(self, token: str) -> None: + path = self.workspace.resolve(self.project_root / f"{token}.json") + if path.is_file(): + path.unlink() + + @staticmethod + def parse_conversation_proposal(content: str) -> ConversationProposal: + text = str(content or "").strip() + if text.startswith("```"): + lines = text.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + text = "\n".join(lines).strip() + try: + payload = json.loads(text) + proposal = ConversationProposal.model_validate(payload) + except (ValueError, ValidationError) as exc: + raise StudioError( + "AUTHORING_MODEL_OUTPUT_INVALID", + "对话构建模型没有返回合法的 Agent Draft Patch", + status_code=502, + details={"reason": str(exc)}, + ) from exc + normalized_slug = AgentAuthoringService.normalize_slug(proposal.slug) + return proposal.model_copy(update={"slug": normalized_slug}) + + @staticmethod + def conversation_messages(messages: list[dict[str, str]]) -> list[dict[str, str]]: + if not messages: + raise StudioError( + "AUTHORING_CONVERSATION_EMPTY", + "对话构建至少需要一条消息", + status_code=422, + ) + normalized: list[dict[str, str]] = [ + { + "role": "system", + "content": ( + "你是 AgentKit Studio 的 Agent 设计助手。根据对话生成一个 JSON Draft Patch," + "不得输出 Markdown。字段必须且只能包含 name、slug、runtimeType、description、" + "instructions;runtimeType 只能是 codex、adk、langgraph;instructions 必须包含" + " system 和 task。只提出配置,不写文件、不宣称已经创建。" + ), + } + ] + for item in messages: + role = str(item.get("role") or "").strip() + content = str(item.get("content") or "").strip() + if role not in {"user", "assistant"} or not content: + raise StudioError( + "AUTHORING_CONVERSATION_INVALID", + "对话消息只允许非空 user/assistant 文本", + status_code=422, + ) + normalized.append({"role": role, "content": content}) + return normalized + + @staticmethod + def authoring_network_policy(endpoint_url: str) -> NetworkPolicy: + host = (urlparse(endpoint_url).hostname or "").lower().rstrip(".") + return NetworkPolicy( + mode="restricted", + allowed_hosts=[host] if host else [], + allow_private_network=False, + ) + + def _stage_import( + self, + staging: Path, + content: bytes, + *, + filename: str, + ) -> tuple[list[str], Path]: + if filename.lower().endswith(".zip"): + return self._extract_zip(staging, content) + if not filename.lower().endswith((".yaml", ".yml")): + raise StudioError( + "AGENT_IMPORT_FORMAT_UNSUPPORTED", + "仅支持 Agent YAML 或 ZIP", + status_code=422, + ) + path = staging / "agent.yaml" + path.write_bytes(content) + return ["agent.yaml"], path + + def _extract_zip(self, staging: Path, content: bytes) -> tuple[list[str], Path]: + try: + archive = zipfile.ZipFile(io.BytesIO(content)) + except zipfile.BadZipFile as exc: + raise StudioError( + "AGENT_IMPORT_ARCHIVE_INVALID", + "Agent ZIP 无法解析", + status_code=422, + ) from exc + with archive: + entries = [item for item in archive.infolist() if not item.is_dir()] + if not entries or len(entries) > _MAX_IMPORT_FILES: + raise StudioError( + "AGENT_IMPORT_ARCHIVE_INVALID", + "Agent ZIP 文件数量不合法", + status_code=422, + ) + expanded = sum(item.file_size for item in entries) + if expanded > _MAX_IMPORT_BYTES: + raise StudioError( + "AGENT_IMPORT_TOO_LARGE", + "Agent ZIP 解压后不能超过 100 MiB", + status_code=413, + ) + files: list[str] = [] + manifests: list[Path] = [] + for item in entries: + path = PurePosixPath(item.filename) + mode = (item.external_attr >> 16) & 0o170000 + if ( + path.is_absolute() + or ".." in path.parts + or mode in {stat.S_IFLNK, stat.S_IFCHR, stat.S_IFBLK, stat.S_IFIFO} + or (item.compress_size == 0 and item.file_size > 0) + or ( + item.compress_size > 0 + and item.file_size / item.compress_size > 200 + ) + ): + raise StudioError( + "AGENT_IMPORT_ARCHIVE_UNSAFE", + "Agent ZIP 包含不安全路径、链接或压缩条目", + status_code=422, + details={"entry": item.filename}, + ) + target = staging / Path(*path.parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(item)) + files.append(path.as_posix()) + if path.name in {"agent.yaml", "agentengine.yaml"}: + manifests.append(target) + if len(manifests) != 1: + raise StudioError( + "AGENT_IMPORT_MANIFEST_REQUIRED", + "Agent ZIP 必须且只能包含一个 agent.yaml 或 agentengine.yaml", + status_code=422, + ) + return sorted(files), manifests[0] + + @staticmethod + def _load_yaml_object(path: Path) -> dict[str, Any]: + try: + payload = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc: + raise StudioError( + "AGENT_IMPORT_MANIFEST_INVALID", + "Agent YAML 无法解析", + status_code=422, + ) from exc + if not isinstance(payload, dict): + raise StudioError( + "AGENT_IMPORT_MANIFEST_INVALID", + "Agent YAML 根节点必须是对象", + status_code=422, + ) + return cast(dict[str, Any], payload) + + @staticmethod + def _classify_import(payload: dict[str, Any]) -> tuple[str, str, str]: + try: + if payload.get("kind") == "Agent": + draft = AgentDraft.model_validate(payload) + if draft.spec.runtime is None: + raise ValueError("Agent Draft 缺少 spec.runtime") + return "agent-draft", draft.spec.runtime.type, draft.metadata.name + manifest = CodexAgentManifest.model_validate(payload) + return "codex-manifest", "codex", manifest.name + except (ValueError, ValidationError) as exc: + raise StudioError( + "AGENT_IMPORT_CONTRACT_INVALID", + "导入内容不符合 Agent Draft 或 Codex agentengine.yaml 合同", + status_code=422, + details={"reason": str(exc)}, + ) from exc + + @staticmethod + def _tree_digest(root: Path, *, exclude: set[str] | None = None) -> str: + ignored = exclude or set() + entries: list[dict[str, Any]] = [] + for path in sorted(root.rglob("*"), key=lambda item: item.as_posix()): + if path.is_symlink(): + raise StudioError( + "AUTHORING_SOURCE_UNSAFE", + "待检查内容包含软链接", + status_code=422, + details={"path": str(path)}, + ) + if not path.is_file() or path.name in ignored: + continue + relative = path.relative_to(root).as_posix() + content = path.read_bytes() + entries.append( + { + "path": relative, + "sha256": hashlib.sha256(content).hexdigest(), + "size": len(content), + } + ) + return sha256_digest(canonical_json(entries)) + + def _token_directory(self, root: Path, token: str, *, prefix: str) -> Path: + if not re.fullmatch(rf"{re.escape(prefix)}[0-9a-f]{{32}}", token): + raise StudioError( + "AGENT_IMPORT_INSPECTION_NOT_FOUND", + "导入检查不存在或已失效", + status_code=404, + ) + path = self.workspace.resolve(root / token) + if not path.is_dir(): + raise StudioError( + "AGENT_IMPORT_INSPECTION_NOT_FOUND", + "导入检查不存在或已失效", + status_code=404, + ) + return path + + @staticmethod + def _public_import_record(record: dict[str, Any]) -> dict[str, Any]: + return { + "inspectionToken": record["token"], + "kind": record["kind"], + "runtimeType": record["runtimeType"], + "displayName": record["displayName"], + "sourceDigest": record["sourceDigest"], + "files": record["files"], + "requiresConfirmation": True, + } + + +__all__ = ["AgentAuthoringService", "ConversationProposal", "ImportInspection"] diff --git a/ksadk/studio/authoring_coordinator.py b/ksadk/studio/authoring_coordinator.py new file mode 100644 index 00000000..5be79dca --- /dev/null +++ b/ksadk/studio/authoring_coordinator.py @@ -0,0 +1,285 @@ +"""Application-level commits for Agent authoring inspections.""" + +from __future__ import annotations + +import os +import shutil +import threading +from pathlib import Path +from typing import Any, cast + +from ksadk.studio.authoring import AgentAuthoringService +from ksadk.studio.codex_manifest import CodexAgentManifest +from ksadk.studio.contracts import ( + AgentBindings, + AgentDraft, + AgentSpec, + Instructions, + ModelSpec, + RuntimeRef, +) +from ksadk.studio.errors import StudioError +from ksadk.studio.identifiers import generate_agent_slug, is_generated_agent_slug +from ksadk.studio.templates import default_agent_spec + + +class StudioAuthoringCoordinator: + """Coordinates repositories without expanding the StudioService façade.""" + + def __init__(self, studio: Any) -> None: + self.studio = studio + self.backend = AgentAuthoringService(studio.workspace) + self._id_lock = threading.Lock() + + def create( + self, + *, + name: str, + slug: str | None = None, + runtime_type: str, + template: str = "blank", + description: str = "", + spec: AgentSpec | None = None, + ) -> AgentDraft: + with self._id_lock: + agent_id = self._allocate_agent_id(slug) + resolved_slug = slug or agent_id + resolved = (spec or default_agent_spec(template, description=description)).model_copy( + deep=True + ) + resolved.runtime = self.backend.runtime_ref(agent_id, runtime_type) + if description: + resolved.description = description + draft = self.studio.create_studio_agent( + agent_id=agent_id, + name=name, + description=description, + template=template, + spec=resolved, + labels={"agentkit.ksyun.com/slug": self.backend.normalize_slug(resolved_slug)}, + ) + return cast(AgentDraft, draft) + + def inspect_import(self, content: bytes, *, filename: str) -> dict: + return self.backend.inspect_import(content, filename=filename) + + def commit_import( + self, + inspection_token: str, + *, + name: str | None = None, + slug: str | None = None, + ) -> AgentDraft: + inspection = self.backend.load_import(inspection_token) + display_name = str(name or inspection.display_name).strip() + agent_id = self._allocate_agent_id(slug) + resolved_slug = slug or agent_id + if inspection.kind == "codex-manifest": + created = self._commit_codex_import( + agent_id, + display_name, + CodexAgentManifest.model_validate(inspection.payload), + resolved_slug=resolved_slug, + ) + self.backend.consume_import(inspection_token) + return created + + imported = AgentDraft.model_validate(inspection.payload) + spec = imported.spec.model_copy(deep=True) + source = self.backend.source_directory(inspection) + if spec.runtime is None: + raise StudioError( + "AGENT_RUNTIME_REQUIRED", + "导入 Agent 必须声明 RuntimeRef", + status_code=422, + ) + if spec.runtime.type in {"adk", "langgraph"}: + spec.runtime = self.backend.runtime_ref(agent_id, spec.runtime.type) + created = self.studio.create_agent( + agent_id=agent_id, + name=display_name, + description=spec.description, + template=imported.metadata.labels.get( + "agentkit.ksyun.com/template", + "blank", + ), + spec=spec, + labels={ + "agentkit.ksyun.com/slug": self.backend.normalize_slug(resolved_slug), + "agentkit.ksyun.com/source": "import", + "agentkit.ksyun.com/source-digest": inspection.source_digest, + }, + ) + if source is not None and created.spec.runtime and created.spec.runtime.project_path: + target = self.studio.workspace.resolve(created.spec.runtime.project_path) + shutil.rmtree(target, ignore_errors=True) + shutil.copytree( + source, + target, + symlinks=False, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store"), + ) + self.backend.consume_import(inspection_token) + return cast(AgentDraft, self.studio.drafts.get(agent_id)) + + def inspect_project(self, project_path: str) -> dict: + return self.backend.inspect_project(project_path) + + def commit_project( + self, + inspection_token: str, + *, + name: str | None = None, + slug: str | None = None, + model_profile_id: str | None = None, + ) -> AgentDraft: + inspection = self.backend.load_project(inspection_token) + runtime_type = str(inspection["runtimeType"]) + display_name = str(name or inspection.get("name") or "Imported Agent").strip() + resolved_slug = slug + if runtime_type == "codex": + manifest_path = self.studio.workspace.resolve( + Path(str(inspection["projectPath"])) / "agentengine.yaml", + must_exist=True, + ) + created = self.commit_import( + self.inspect_import( + manifest_path.read_bytes(), + filename="agentengine.yaml", + )["inspectionToken"], + name=display_name, + slug=resolved_slug, + ) + self.backend.consume_project(inspection_token) + return created + + agent_id = self._allocate_agent_id(resolved_slug) + resolved_slug = resolved_slug or agent_id + config = inspection.get("evidence", {}).get("config", {}) + prompt = str( + config.get("prompt") + or config.get("instruction") + or "You are a reliable assistant imported from an existing project." + ) + runtime = RuntimeRef( + type=cast(Any, runtime_type), + project_path=str(inspection["projectPath"]), + entry_point=str(inspection.get("entryPoint") or "agent.py"), + agent_variable=str( + inspection.get("agentVariable") + or ("graph" if runtime_type == "langgraph" else "root_agent") + ), + detection="auto", + ) + created = self.studio.create_agent( + agent_id=agent_id, + name=display_name, + spec=AgentSpec( + runtime=runtime, + instructions=Instructions(system=prompt), + bindings=AgentBindings(model_profile_id=model_profile_id), + ), + labels={ + "agentkit.ksyun.com/slug": self.backend.normalize_slug(resolved_slug), + "agentkit.ksyun.com/source": "project-detection", + "agentkit.ksyun.com/source-digest": str(inspection["sourceDigest"]), + }, + ) + self.backend.consume_project(inspection_token) + return cast(AgentDraft, created) + + async def compose_conversation( + self, + *, + messages: list[dict[str, str]], + model_profile_id: str, + ) -> dict: + model_spec = self.studio.catalog.resolve_model( + AgentBindings(model_profile_id=model_profile_id) + ) + if model_spec is None: + raise StudioError( + "AGENT_MODEL_REQUIRED", + "对话构建需要选择一个 Model Profile", + status_code=422, + ) + model = self.studio.catalog.resolver.resolve_model(model_spec) + response = await self.studio.model_client.complete( + model, + messages=self.backend.conversation_messages(messages), + network_policy=self.backend.authoring_network_policy(model.endpoint_url), + timeout_seconds=60, + max_attempts=2, + backoff_seconds=1, + ) + proposal = self.backend.parse_conversation_proposal(response.content) + return { + "proposal": proposal.model_dump(mode="json"), + "requiresConfirmation": True, + "usage": response.usage.model_dump(by_alias=True, mode="json"), + } + + def _agent_exists(self, agent_id: str) -> bool: + return bool( + self.studio.codex_manifests.exists(agent_id) + or self.studio._draft_exists(agent_id) + or (self.studio.workspace.resolve("agents") / agent_id).exists() + ) + + def _allocate_agent_id(self, slug: str | None) -> str: + if slug and slug.strip(): + if is_generated_agent_slug(slug): + candidate = slug.strip() + if self._agent_exists(candidate): + raise StudioError( + "AGENT_ALREADY_EXISTS", + "本地标识已存在,请重新生成", + status_code=409, + field="slug", + details={"id": candidate}, + ) + return candidate + return self.backend.allocate_agent_id(slug) + return generate_agent_slug(self._agent_exists) + + def _commit_codex_import( + self, + agent_id: str, + display_name: str, + manifest: CodexAgentManifest, + *, + resolved_slug: str, + ) -> AgentDraft: + upstream = ( + (os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_BASE") or "") + .strip() + .rstrip("/") + ) + if upstream.endswith("/chat/completions"): + model_endpoint: dict[str, str] = {"endpoint_url": upstream} + else: + model_endpoint = {"base_url": upstream or "https://api.openai.com/v1"} + spec = AgentSpec( + runtime=RuntimeRef(type="codex", version=manifest.runtime.version), + instructions=Instructions(system=manifest.prompt), + model=ModelSpec( + model=manifest.model, + credential_ref="env://AGENTKIT_MODEL_API_KEY", + **model_endpoint, + ), + ) + return cast( + AgentDraft, + self.studio.create_codex_agent( + agent_id=agent_id, + spec=spec, + name=display_name, + labels={ + "agentkit.ksyun.com/slug": self.backend.normalize_slug(resolved_slug), + "agentkit.ksyun.com/source": "import", + }, + ), + ) + + +__all__ = ["StudioAuthoringCoordinator"] diff --git a/ksadk/studio/builder.py b/ksadk/studio/builder.py new file mode 100644 index 00000000..d9ee4bb1 --- /dev/null +++ b/ksadk/studio/builder.py @@ -0,0 +1,320 @@ +"""Deterministic local AgentBundle builder.""" + +from __future__ import annotations + +import hashlib +import shutil +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +from ksadk.studio.capabilities import canonical_json, sha256_digest +from ksadk.studio.compiler import AgentCompiler +from ksadk.studio.contracts import ( + AgentDraft, + BuildRecord, + BuildStatus, + BundleManifest, + FileEntry, +) +from ksadk.studio.repository import BuildRepository +from ksadk.studio.workspace import Workspace + +_ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0) + + +class AgentBundleBuilder: + def __init__( + self, + workspace: Workspace, + *, + compiler: AgentCompiler | None = None, + repository: BuildRepository | None = None, + ) -> None: + self.workspace = workspace + self.compiler = compiler or AgentCompiler(workspace) + self.repository = repository or BuildRepository(workspace) + + def build(self, draft: AgentDraft) -> BuildRecord: + compiled = self.compiler.compile(draft) + runtime_type, source_digest, runtime_lock = self._runtime_snapshot(draft, compiled) + resolved_digest = sha256_digest( + canonical_json( + { + "definitionDigest": compiled.resolved.resolved_digest, + "runtime": runtime_lock, + "sourceDigest": source_digest, + } + ) + ) + short_digest = resolved_digest.removeprefix("sha256:")[:20] + build_id = f"build_{short_digest}" + final_dir = self.workspace.resolve(Path("dist") / draft.metadata.id / build_id) + zip_path = final_dir / "agent-bundle.zip" + if zip_path.is_file(): + return self.repository.get(build_id) + + staging = self.workspace.resolve( + Path(".agentkit/builds") / f".{build_id}.{uuid4().hex}.tmp" + ) + bundle_root = staging / "agent-bundle" + bundle_root.mkdir(parents=True, exist_ok=False) + try: + self._write_payload( + bundle_root, + draft, + compiled, + runtime_lock=runtime_lock, + resolved_digest=resolved_digest, + ) + self._copy_runtime_source(bundle_root, draft) + files = self._file_entries(bundle_root) + manifest = BundleManifest( + agent_id=draft.metadata.id, + source_revision=draft.metadata.revision, + resolved_digest=resolved_digest, + runtime_type=runtime_type, + source_digest=source_digest, + files=files, + ) + digest_payload = manifest.model_dump( + by_alias=True, + exclude={"bundle_digest"}, + exclude_none=True, + mode="json", + ) + manifest.bundle_digest = sha256_digest(canonical_json(digest_payload)) + self._write_json(bundle_root / "manifest.json", manifest.model_dump(by_alias=True)) + self._write_checksums(bundle_root) + archive = staging / "agent-bundle.zip" + self._write_zip(bundle_root, archive) + final_dir.parent.mkdir(parents=True, exist_ok=True) + final_dir.mkdir(parents=True, exist_ok=False) + shutil.move(str(bundle_root), str(final_dir / "agent-bundle")) + shutil.move(str(archive), str(zip_path)) + finally: + shutil.rmtree(staging, ignore_errors=True) + + now = datetime.now(timezone.utc) + record = BuildRecord( + id=build_id, + agent_id=draft.metadata.id, + source_revision=draft.metadata.revision, + status=BuildStatus.SUCCEEDED, + resolved_digest=resolved_digest, + runtime_type=runtime_type, + source_digest=source_digest, + runtime_lock=runtime_lock, + bundle_digest=manifest.bundle_digest, + artifact_path=self.workspace.relative(zip_path), + created_at=now, + completed_at=now, + ) + return self.repository.save(record) + + def _write_payload( + self, + root: Path, + draft: AgentDraft, + compiled, + *, + runtime_lock: dict, + resolved_digest: str, + ) -> None: + definition_digest = compiled.resolved.resolved_digest + resolved_payload = compiled.resolved.model_dump( + by_alias=True, + exclude_none=True, + mode="json", + ) + resolved_payload["resolvedDigest"] = resolved_digest + dependency_lock = dict(compiled.dependency_lock) + dependency_lock["definitionDigest"] = definition_digest + dependency_lock["resolvedDigest"] = resolved_digest + self._write_json( + root / "resolved-agent-spec.json", + resolved_payload, + ) + self._write_json(root / "agentkit.lock", dependency_lock) + self._write_json(root / "runtime-lock.json", runtime_lock) + instructions = root / "instructions" + instructions.mkdir() + (instructions / "system.md").write_text( + draft.spec.instructions.system.rstrip() + "\n", encoding="utf-8" + ) + (instructions / "task.md").write_text( + draft.spec.instructions.task.rstrip() + "\n", encoding="utf-8" + ) + for skill in compiled.resolved.capabilities.skills: + source = self.workspace.resolve( + Path("capabilities/skills") / skill["name"], + must_exist=True, + ) + target = root / skill["bundlePath"] + shutil.copytree( + source, + target, + symlinks=False, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store"), + ) + components = [] + for kind, values in ( + ("skill", compiled.resolved.capabilities.skills), + ("mcp", compiled.resolved.capabilities.mcp_servers), + ): + for value in values: + components.append( + { + "type": kind, + "name": value["name"], + "version": value["version"], + "digest": value["digest"], + } + ) + for tool in compiled.resolved.capabilities.tools: + components.append( + { + "type": "tool", + "name": tool.name, + "version": tool.version, + "digest": tool.digest, + } + ) + components.sort(key=lambda item: (item["type"], item["name"], item["version"])) + self._write_json( + root / "sbom.spdx.json", + { + "spdxVersion": "SPDX-2.3", + "name": f"{draft.metadata.id}-agent-bundle", + "components": components, + }, + ) + self._write_json( + root / "provenance.json", + { + "format": "agentkit.provenance/v1", + "agentId": draft.metadata.id, + "sourceRevision": draft.metadata.revision, + "sourceDigest": compiled.resolved.source_digest, + "definitionDigest": definition_digest, + "resolvedDigest": resolved_digest, + "compilerVersion": compiled.resolved.compiler_version, + "runtimeContract": "agentkit.runtime/v1", + }, + ) + + def _runtime_snapshot(self, draft: AgentDraft, compiled) -> tuple[str, str, dict]: + runtime = draft.spec.runtime + runtime_type = runtime.type if runtime is not None else "" + source_digest = "" + source_files: list[dict[str, object]] = [] + if runtime is not None and runtime.project_path: + source_root = self.workspace.resolve(runtime.project_path, must_exist=True) + for path in self._source_files(source_root): + content = path.read_bytes() + relative = path.relative_to(source_root).as_posix() + digest = f"sha256:{hashlib.sha256(content).hexdigest()}" + source_files.append( + {"path": relative, "sha256": digest, "size": len(content)} + ) + source_digest = sha256_digest(canonical_json(source_files)) + bound_models = [ + item.model for item in self.compiler.catalog.resolve_models(draft.spec.bindings) + ] + if compiled.resolved.model.model not in bound_models: + bound_models.insert(0, compiled.resolved.model.model) + lock = { + "type": runtime_type, + "projectPath": runtime.project_path if runtime is not None else None, + "entryPoint": runtime.entry_point if runtime is not None else None, + "agentVariable": runtime.agent_variable if runtime is not None else None, + "version": runtime.version if runtime is not None else None, + "detection": runtime.detection if runtime is not None else None, + "sourceDigest": source_digest, + "definitionDigest": compiled.resolved.resolved_digest, + "model": compiled.resolved.model.model, + "models": list(dict.fromkeys(bound_models)), + } + return runtime_type, source_digest, { + key: value for key, value in lock.items() if value is not None + } + + def _copy_runtime_source(self, bundle_root: Path, draft: AgentDraft) -> None: + runtime = draft.spec.runtime + if runtime is None or not runtime.project_path: + return + source_root = self.workspace.resolve(runtime.project_path, must_exist=True) + target_root = bundle_root / "runtime" + for source in self._source_files(source_root): + relative = source.relative_to(source_root) + target = target_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(source.read_bytes()) + + @staticmethod + def _source_files(root: Path) -> list[Path]: + files: list[Path] = [] + for path in sorted(root.rglob("*"), key=lambda item: item.as_posix()): + if path.is_symlink(): + raise ValueError(f"Runtime source cannot contain symlinks: {path}") + if not path.is_file(): + continue + relative = path.relative_to(root) + if any(part in {"__pycache__", ".git", ".venv"} for part in relative.parts): + continue + if path.suffix in {".pyc", ".pyo"}: + continue + files.append(path) + return files + + @staticmethod + def _write_json(path: Path, payload: object) -> None: + path.write_bytes(canonical_json(payload) + b"\n") + + @staticmethod + def _file_entries(root: Path) -> list[FileEntry]: + entries = [] + for path in sorted( + (item for item in root.rglob("*") if item.is_file()), + key=lambda item: item.relative_to(root).as_posix(), + ): + content = path.read_bytes() + entries.append( + FileEntry( + path=path.relative_to(root).as_posix(), + sha256=f"sha256:{hashlib.sha256(content).hexdigest()}", + size=len(content), + ) + ) + return entries + + @staticmethod + def _write_checksums(root: Path) -> None: + lines = [] + for path in sorted( + (item for item in root.rglob("*") if item.is_file()), + key=lambda item: item.relative_to(root).as_posix(), + ): + relative = path.relative_to(root).as_posix() + lines.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {relative}") + (root / "checksums.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + + @staticmethod + def _write_zip(root: Path, target: Path) -> None: + with zipfile.ZipFile( + target, + "w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as archive: + for path in sorted( + (item for item in root.rglob("*") if item.is_file()), + key=lambda item: item.relative_to(root).as_posix(), + ): + relative = path.relative_to(root).as_posix() + info = zipfile.ZipInfo(relative, _ZIP_TIMESTAMP) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + info.external_attr = 0o100644 << 16 + archive.writestr(info, path.read_bytes(), compresslevel=9) diff --git a/ksadk/studio/capabilities.py b/ksadk/studio/capabilities.py new file mode 100644 index 00000000..1bd754ff --- /dev/null +++ b/ksadk/studio/capabilities.py @@ -0,0 +1,351 @@ +"""Capability resolution for local Skills, MCP servers, and Tool contracts.""" + +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path +from typing import Any, Protocol, cast + +from ksadk.studio.contracts import ( + CapabilityRef, + MCPServerRef, + ModelSpec, + ResolvedModel, + ToolContract, +) +from ksadk.studio.errors import StudioError +from ksadk.studio.repository import load_yaml_file +from ksadk.studio.workspace import Workspace + +_EXACT_SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$") + + +def canonical_json(payload: Any) -> bytes: + return json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def sha256_digest(payload: bytes) -> str: + return f"sha256:{hashlib.sha256(payload).hexdigest()}" + + +def require_exact_version(version: str, *, field: str) -> None: + if not _EXACT_SEMVER.fullmatch(version): + raise StudioError( + "CAPABILITY_VERSION_MUTABLE", + "能力依赖必须使用确定的语义化版本", + status_code=422, + field=field, + details={"version": version}, + ) + + +class CapabilityResolver(Protocol): + def resolve_model(self, spec: ModelSpec) -> ResolvedModel: ... + + def resolve_skill(self, ref: CapabilityRef) -> dict[str, Any]: ... + + def resolve_mcp(self, ref: MCPServerRef) -> dict[str, Any]: ... + + def resolve_tool(self, contract: ToolContract) -> ToolContract: ... + + +class LocalCapabilityResolver: + def __init__(self, workspace: Workspace) -> None: + self.workspace = workspace + + def resolve_model(self, spec: ModelSpec) -> ResolvedModel: + endpoint = self._normalize_endpoint(spec) + return ResolvedModel( + provider=spec.provider, + model=spec.model, + endpoint_url=endpoint, + credential_ref=spec.credential_ref, + parameters=spec.parameters, + wire_api=spec.wire_api, + ) + + def resolve_skill(self, ref: CapabilityRef) -> dict[str, Any]: + require_exact_version(ref.version, field=f"spec.capabilities.skills[{ref.name}].version") + root = self.workspace.resolve(Path("capabilities/skills") / ref.name) + if not root.is_dir(): + raise StudioError( + "CAPABILITY_UNRESOLVED", + "本地 Skill 不存在", + status_code=422, + details={"kind": "skill", "name": ref.name}, + ) + digest = self._directory_digest(root) + if ref.digest and ref.digest != digest: + raise StudioError( + "CAPABILITY_DIGEST_MISMATCH", + "Skill 内容与声明 digest 不一致", + status_code=422, + details={"name": ref.name, "expected": ref.digest, "actual": digest}, + ) + manifest_path = root / "skill.yaml" + manifest = load_yaml_file(manifest_path) if manifest_path.is_file() else {} + instructions_file = str(manifest.get("instructionsFile") or "SKILL.md") + instructions_path = self.workspace.resolve(root / instructions_file) + instructions = ( + instructions_path.read_text(encoding="utf-8") + if instructions_path.is_file() + else "" + ) + return { + "name": ref.name, + "version": ref.version, + "digest": digest, + "bundlePath": f"capabilities/skills/{ref.name}", + "instructions": instructions, + } + + def resolve_mcp(self, ref: MCPServerRef) -> dict[str, Any]: + require_exact_version( + ref.version, + field=f"spec.capabilities.mcpServers[{ref.name}].version", + ) + payload = ref.model_dump(by_alias=True, exclude_none=True, mode="json") + payload.pop("digest", None) + digest = sha256_digest(canonical_json(payload)) + if ref.digest and ref.digest != digest: + raise StudioError( + "CAPABILITY_DIGEST_MISMATCH", + "MCP 配置与声明 digest 不一致", + status_code=422, + details={"name": ref.name, "expected": ref.digest, "actual": digest}, + ) + payload["digest"] = digest + return cast(dict[str, Any], payload) + + def resolve_tool(self, contract: ToolContract) -> ToolContract: + require_exact_version( + contract.version, + field=f"spec.capabilities.tools[{contract.name}].version", + ) + payload = contract.model_dump(by_alias=True, exclude_none=True, mode="json") + payload.pop("digest", None) + digest = sha256_digest(canonical_json(payload)) + if contract.digest and contract.digest != digest: + raise StudioError( + "CAPABILITY_DIGEST_MISMATCH", + "Tool 合同与声明 digest 不一致", + status_code=422, + details={"name": contract.name, "expected": contract.digest, "actual": digest}, + ) + return cast( + ToolContract, + contract.model_copy(update={"digest": digest}), + ) + + @staticmethod + def _normalize_endpoint(spec: ModelSpec) -> str: + if spec.endpoint_url: + return spec.endpoint_url.rstrip("/") + assert spec.base_url + base = spec.base_url.rstrip("/") + suffix = "/responses" if spec.wire_api == "responses" else "/chat/completions" + return f"{base}{suffix}" + + def _directory_digest(self, root: Path) -> str: + digest = hashlib.sha256() + files = [path for path in root.rglob("*") if path.is_file() and not path.is_symlink()] + for path in sorted(files, key=lambda item: item.relative_to(root).as_posix()): + relative = path.relative_to(root).as_posix().encode("utf-8") + digest.update(len(relative).to_bytes(4, "big")) + digest.update(relative) + content = path.read_bytes() + digest.update(len(content).to_bytes(8, "big")) + digest.update(content) + return f"sha256:{digest.hexdigest()}" + + +def builtin_tool_contracts() -> dict[str, ToolContract]: + return { + "builtin.echo": ToolContract( + name="builtin.echo", + version="1.0.0", + description="回显结构化输入", + input_schema={"type": "object", "additionalProperties": True}, + output_schema={"type": "object", "additionalProperties": True}, + side_effect="none", + ), + "builtin.current_time": ToolContract( + name="builtin.current_time", + version="1.0.0", + description="返回指定时区的当前时间", + input_schema={ + "type": "object", + "properties": {"timezone": {"type": "string"}}, + "additionalProperties": False, + }, + output_schema={ + "type": "object", + "required": ["iso8601", "timezone"], + "properties": { + "iso8601": {"type": "string"}, + "timezone": {"type": "string"}, + }, + }, + permissions=["system:time:read"], + side_effect="read", + ), + "workspace.read": ToolContract( + name="workspace.read", + version="1.0.0", + description="读取当前 Agent 工作区内的 UTF-8 文本文件", + input_schema={ + "type": "object", + "required": ["path"], + "properties": { + "path": {"type": "string"}, + "maxChars": { + "type": "integer", + "minimum": 1, + "maximum": 200000, + "default": 50000, + }, + }, + "additionalProperties": False, + }, + output_schema={ + "type": "object", + "required": ["path", "content", "truncated"], + "properties": { + "path": {"type": "string"}, + "content": {"type": "string"}, + "truncated": {"type": "boolean"}, + }, + }, + permissions=["workspace:file:read"], + side_effect="read", + ), + "workspace.write": ToolContract( + name="workspace.write", + version="1.0.0", + description="在当前 Agent 工作区内写入 UTF-8 文本文件", + input_schema={ + "type": "object", + "required": ["path", "content"], + "properties": { + "path": {"type": "string"}, + "content": {"type": "string", "maxLength": 1000000}, + }, + "additionalProperties": False, + }, + output_schema={ + "type": "object", + "required": ["path", "bytesWritten"], + "properties": { + "path": {"type": "string"}, + "bytesWritten": {"type": "integer"}, + }, + }, + permissions=["workspace:file:write"], + side_effect="write", + approval="always", + ), + "workspace.edit": ToolContract( + name="workspace.edit", + version="1.0.0", + description="在当前 Agent 工作区文本文件中执行精确替换", + input_schema={ + "type": "object", + "required": ["path", "oldText", "newText"], + "properties": { + "path": {"type": "string"}, + "oldText": {"type": "string", "minLength": 1}, + "newText": {"type": "string"}, + "replaceAll": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, + }, + output_schema={ + "type": "object", + "required": ["path", "replacements"], + "properties": { + "path": {"type": "string"}, + "replacements": {"type": "integer"}, + }, + }, + permissions=["workspace:file:read", "workspace:file:write"], + side_effect="write", + approval="always", + ), + "workspace.glob": ToolContract( + name="workspace.glob", + version="1.0.0", + description="按 glob pattern 列出当前 Agent 工作区文件", + input_schema={ + "type": "object", + "required": ["pattern"], + "properties": { + "pattern": {"type": "string"}, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200, + }, + }, + "additionalProperties": False, + }, + output_schema={ + "type": "object", + "required": ["matches", "truncated"], + "properties": { + "matches": {"type": "array", "items": {"type": "string"}}, + "truncated": {"type": "boolean"}, + }, + }, + permissions=["workspace:file:list"], + side_effect="read", + ), + "workspace.grep": ToolContract( + name="workspace.grep", + version="1.0.0", + description="在当前 Agent 工作区 UTF-8 文本文件中搜索字符串", + input_schema={ + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string", "minLength": 1}, + "filePattern": {"type": "string", "default": "**/*"}, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200, + }, + }, + "additionalProperties": False, + }, + output_schema={ + "type": "object", + "required": ["matches", "truncated"], + "properties": { + "matches": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "line", "text"], + "properties": { + "path": {"type": "string"}, + "line": {"type": "integer"}, + "text": {"type": "string"}, + }, + }, + }, + "truncated": {"type": "boolean"}, + }, + }, + permissions=["workspace:file:read", "workspace:file:list"], + side_effect="read", + ), + } diff --git a/ksadk/studio/cloud.py b/ksadk/studio/cloud.py new file mode 100644 index 00000000..b99a5af8 --- /dev/null +++ b/ksadk/studio/cloud.py @@ -0,0 +1,278 @@ +"""Cloud Artifact Admission and Deployment gateway contracts.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any, Protocol, cast +from uuid import uuid4 + +import httpx + +from ksadk.studio.contracts import ( + BuildStatus, + DeploymentRecord, + DeploymentRequest, +) +from ksadk.studio.errors import StudioError +from ksadk.studio.repository import BuildRepository +from ksadk.studio.workspace import Workspace + + +class CloudDeploymentGateway(Protocol): + async def upload_bundle( + self, + *, + bundle: bytes, + bundle_digest: str, + provenance: dict[str, Any], + ) -> str: ... + + async def create_version( + self, + *, + agent_id: str, + bundle_uri: str, + bundle_digest: str, + provenance: dict[str, Any], + ) -> str: ... + + async def create_deployment( + self, + *, + build_id: str, + version_id: str, + bundle_digest: str, + request: DeploymentRequest, + ) -> DeploymentRecord: ... + + +class UnavailableCloudGateway: + async def upload_bundle(self, **_kwargs) -> str: + raise StudioError( + "CLOUD_BUNDLE_ADMISSION_UNAVAILABLE", + "当前未配置支持 AgentBundle Admission 的云端控制面", + status_code=501, + ) + + async def create_version(self, **_kwargs) -> str: + raise AssertionError("upload_bundle must fail first") + + async def create_deployment(self, **_kwargs) -> DeploymentRecord: + raise AssertionError("upload_bundle must fail first") + + +class InMemoryCloudGateway: + """Contract-test gateway; it records exactly what would cross the cloud boundary.""" + + def __init__(self) -> None: + self.uploads: list[dict[str, Any]] = [] + self.versions: list[dict[str, Any]] = [] + self.deployments: list[DeploymentRecord] = [] + + async def upload_bundle(self, **kwargs) -> str: + self.uploads.append(kwargs) + return f"memory://artifacts/{kwargs['bundle_digest']}" + + async def create_version(self, **kwargs) -> str: + self.versions.append(kwargs) + return f"ver_{uuid4().hex}" + + async def create_deployment(self, **kwargs) -> DeploymentRecord: + record = DeploymentRecord( + id=f"dep_{uuid4().hex}", + build_id=kwargs["build_id"], + bundle_digest=kwargs["bundle_digest"], + version_id=kwargs["version_id"], + status="READY", + target=kwargs["request"].target, + ) + self.deployments.append(record) + return record + + +class HttpCloudDeploymentGateway: + def __init__(self, *, base_url: str, bearer_token: str) -> None: + self.base_url = base_url.rstrip("/") + self.bearer_token = bearer_token + + async def upload_bundle(self, **kwargs) -> str: + headers = {"Authorization": f"Bearer {self.bearer_token}"} + async with httpx.AsyncClient(follow_redirects=False, timeout=60) as client: + create = await client.post( + f"{self.base_url}/v1/artifact-uploads", + headers=headers, + json={ + "bundleDigest": kwargs["bundle_digest"], + "size": len(kwargs["bundle"]), + "provenance": kwargs["provenance"], + }, + ) + self._raise(create) + upload = create.json() + put = await client.put( + upload["uploadUrl"], + content=kwargs["bundle"], + headers=upload.get("headers") or {}, + ) + self._raise(put) + return str(upload["artifactUri"]) + + async def create_version(self, **kwargs) -> str: + async with httpx.AsyncClient(follow_redirects=False, timeout=30) as client: + response = await client.post( + f"{self.base_url}/v1/agents/{kwargs['agent_id']}/versions", + headers={"Authorization": f"Bearer {self.bearer_token}"}, + json={ + "bundle": { + "uri": kwargs["bundle_uri"], + "digest": kwargs["bundle_digest"], + }, + "provenance": kwargs["provenance"], + }, + ) + self._raise(response) + return str(response.json()["versionId"]) + + async def create_deployment(self, **kwargs) -> DeploymentRecord: + request: DeploymentRequest = kwargs["request"] + async with httpx.AsyncClient(follow_redirects=False, timeout=30) as client: + response = await client.post( + f"{self.base_url}/v1/deployments", + headers={"Authorization": f"Bearer {self.bearer_token}"}, + json={ + "versionId": kwargs["version_id"], + "bundleDigest": kwargs["bundle_digest"], + **request.model_dump(by_alias=True, mode="json"), + }, + ) + self._raise(response) + return cast(DeploymentRecord, DeploymentRecord.model_validate(response.json())) + + @staticmethod + def _raise(response: httpx.Response) -> None: + if response.status_code < 400: + return + raise StudioError( + "CLOUD_ADMISSION_REJECTED", + "云端拒绝 AgentBundle 或 Deployment", + status_code=422, + details={"upstreamStatus": response.status_code}, + ) + + +class CloudDeploymentService: + def __init__( + self, + workspace: Workspace, + *, + gateway: CloudDeploymentGateway, + build_repository: BuildRepository | None = None, + ) -> None: + self.workspace = workspace + self.gateway = gateway + self.build_repository = build_repository or BuildRepository(workspace) + + async def deploy( + self, + build_id: str, + request: DeploymentRequest, + ) -> DeploymentRecord: + build = self.build_repository.get(build_id) + if build.status != BuildStatus.SUCCEEDED or not build.artifact_path: + raise StudioError( + "BUILD_NOT_READY", + "只有成功 Build 可以部署", + status_code=409, + ) + archive = self.workspace.resolve(build.artifact_path, must_exist=True) + manifest_path = archive.parent / "agent-bundle" / "manifest.json" + provenance_path = archive.parent / "agent-bundle" / "provenance.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest.get("bundleDigest") != build.bundle_digest: + raise StudioError( + "BUILD_DIGEST_MISMATCH", + "上传前 Bundle digest 校验失败", + status_code=422, + ) + bundle = archive.read_bytes() + archive_sha256 = f"sha256:{hashlib.sha256(bundle).hexdigest()}" + provenance = json.loads(provenance_path.read_text(encoding="utf-8")) + provenance["archiveSha256"] = archive_sha256 + bundle_uri = await self.gateway.upload_bundle( + bundle=bundle, + bundle_digest=build.bundle_digest, + provenance=provenance, + ) + version_id = await self.gateway.create_version( + agent_id=build.agent_id, + bundle_uri=bundle_uri, + bundle_digest=build.bundle_digest, + provenance=provenance, + ) + record = await self.gateway.create_deployment( + build_id=build_id, + version_id=version_id, + bundle_digest=build.bundle_digest, + request=request, + ) + self._save(record, request) + return record + + def get(self, deployment_id: str) -> DeploymentRecord: + path = self.workspace.resolve( + Path(".agentkit/deployments") / f"{deployment_id}.json" + ) + if not path.is_file(): + raise StudioError( + "DEPLOYMENT_NOT_FOUND", + "Deployment 不存在", + status_code=404, + details={"id": deployment_id}, + ) + payload = json.loads(path.read_text(encoding="utf-8")) + return cast( + DeploymentRecord, + DeploymentRecord.model_validate(payload["record"]), + ) + + async def rollback( + self, + deployment_id: str, + *, + target_build_id: str, + ) -> DeploymentRecord: + path = self.workspace.resolve( + Path(".agentkit/deployments") / f"{deployment_id}.json" + ) + if not path.is_file(): + self.get(deployment_id) + payload = json.loads(path.read_text(encoding="utf-8")) + request = DeploymentRequest.model_validate(payload["request"]) + return await self.deploy(target_build_id, request) + + def _save( + self, + record: DeploymentRecord, + request: DeploymentRequest, + ) -> None: + directory = self.workspace.resolve(".agentkit/deployments") + directory.mkdir(parents=True, exist_ok=True) + self.workspace.atomic_write_text( + directory / f"{record.id}.json", + json.dumps( + { + "record": record.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), + "request": request.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), + }, + ensure_ascii=False, + sort_keys=True, + indent=2, + ) + + "\n", + ) diff --git a/ksadk/studio/codex_agent_service.py b/ksadk/studio/codex_agent_service.py new file mode 100644 index 00000000..c4a34cfd --- /dev/null +++ b/ksadk/studio/codex_agent_service.py @@ -0,0 +1,583 @@ +"""Codex Agent lifecycle behind the common Studio Agent contract. + +The deployable ``agentengine.yaml`` deliberately remains a narrow platform +contract. Studio-only display metadata and bindings live in a sidecar draft so +editing a Codex Agent behaves like every other Runtime without polluting the +ManagedRuntime manifest. +""" + +from __future__ import annotations + +import asyncio +import builtins +import json +import os +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, cast + +from pydantic import ValidationError + +from ksadk.studio.codex_builder import CodexBuildRecord +from ksadk.studio.codex_manifest import ( + CodexAgentManifest, + CodexManifestSnapshot, + CodexRuntimeRef, +) +from ksadk.studio.contracts import ( + AgentAppearance, + AgentBindings, + AgentDraft, + AgentMetadata, + AgentSpec, + Instructions, + ModelSpec, + Operation, + OperationKind, + RunEvent, + RunStatus, + RuntimeRef, +) +from ksadk.studio.errors import StudioError +from ksadk.studio.templates import default_agent_spec +from ksadk.studio.workspace import Workspace + + +class CodexDraftRepository: + """Persist Studio-only Codex metadata separately from deployment YAML.""" + + def __init__(self, workspace: Workspace) -> None: + self.workspace = workspace + + def _path(self, agent_id: str) -> Path: + return self.workspace.resolve(Path(".agentkit/codex-drafts") / f"{agent_id}.json") + + def get(self, agent_id: str) -> AgentDraft | None: + path = self._path(agent_id) + if not path.is_file(): + return None + try: + return cast( + AgentDraft, + AgentDraft.model_validate_json(path.read_text(encoding="utf-8")), + ) + except (OSError, ValidationError) as exc: + raise StudioError( + "CODEX_DRAFT_INVALID", + "Codex Agent 的 Studio 元数据损坏", + status_code=500, + details={"agentId": agent_id}, + ) from exc + + def save(self, draft: AgentDraft) -> AgentDraft: + payload = draft.model_dump( + by_alias=True, + exclude_none=True, + mode="json", + ) + self.workspace.atomic_write_text( + self._path(draft.metadata.id), + json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + ) + return draft + + def delete( + self, + agent_id: str, + *, + purge: bool, + trash_directory: Path | None, + ) -> None: + path = self._path(agent_id) + if not path.is_file(): + return + if purge: + path.unlink() + return + if trash_directory is None: + raise ValueError("recoverable deletion requires a trash directory") + target = self.workspace.resolve(trash_directory / "metadata" / path.name) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(path), str(target)) + + +class CodexAgentService: + """Own Codex definition projection, metadata and operation dispatch.""" + + def __init__(self, studio: Any) -> None: + self.studio = studio + self.drafts = getattr(studio, "codex_drafts", None) or CodexDraftRepository( + studio.workspace + ) + + def manifest_state(self, agent_id: str | None = None) -> dict: + snapshot = self.studio.codex_manifests.load(agent_id) + builds = self._builds(snapshot.manifest.name) + latest = builds[0] if builds else None + return { + "manifest": snapshot.manifest, + "manifestSha256": snapshot.manifest_sha256, + "sourcePath": self.studio.workspace.relative(snapshot.source_path), + "sourceYaml": snapshot.source_bytes.decode("utf-8"), + "buildCurrent": bool(latest and self.studio.codex_builder.is_current(latest)), + "latestBuild": latest, + } + + def save_manifest(self, manifest: CodexAgentManifest) -> dict: + snapshot = self.studio.codex_manifests.save(manifest) + current = self.drafts.get(manifest.name) + if current is not None: + projected = self._project(snapshot, current=current) + projected.metadata.revision = current.metadata.revision + 1 + self.drafts.save(projected) + return self.manifest_state(manifest.name) + + def list(self, *, query: str = "", limit: int = 50) -> list[AgentDraft]: + if limit < 1: + return [] + normalized = query.strip().lower() + results: list[AgentDraft] = [] + for snapshot in self.studio.codex_manifests.list(): + draft = self._project(snapshot) + if ( + normalized + and normalized not in draft.metadata.id.lower() + and (normalized not in draft.metadata.name.lower()) + ): + continue + results.append(draft) + return results[:limit] + + def create( + self, + *, + agent_id: str, + spec: AgentSpec | None, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> AgentDraft: + if self.studio.codex_manifests.exists(agent_id) or self.studio._draft_exists(agent_id): + raise StudioError( + "AGENT_ALREADY_EXISTS", + "本地 Agent 已存在", + status_code=409, + details={"agentId": agent_id}, + ) + resolved = (spec or default_agent_spec("blank")).model_copy(deep=True) + resolved.runtime = RuntimeRef(type="codex", version=self._runtime_version(resolved)) + self.ensure_bindings_supported(resolved) + manifest = self._manifest(agent_id, resolved) + snapshot = self.studio.codex_manifests.save(manifest) + draft = AgentDraft( + metadata=AgentMetadata( + id=agent_id, + name=str(name or agent_id).strip() or agent_id, + labels={**self._labels(manifest), **dict(labels or {})}, + ), + spec=resolved, + ) + self.drafts.save(draft) + return self._project(snapshot) + + def update( + self, + agent_id: str, + spec: AgentSpec, + *, + expected_revision: int, + ) -> AgentDraft: + snapshot = self.studio.codex_manifests.load(agent_id) + current = self._project(snapshot) + if current.metadata.revision != expected_revision: + raise StudioError( + "AGENT_REVISION_CONFLICT", + "Agent 已被其他操作更新", + status_code=409, + details={ + "expected": expected_revision, + "actual": current.metadata.revision, + }, + ) + resolved = spec.model_copy(deep=True) + resolved.runtime = current.spec.runtime + self.ensure_bindings_supported(resolved) + manifest = self._manifest(agent_id, resolved, current=snapshot.manifest) + updated_snapshot = self.studio.codex_manifests.save(manifest) + updated = AgentDraft( + metadata=current.metadata.model_copy( + deep=True, + update={"revision": current.metadata.revision + 1}, + ), + spec=resolved, + ) + updated.metadata.labels.update(self._labels(manifest)) + self.drafts.save(updated) + return self._project(updated_snapshot) + + def update_appearance( + self, + agent_id: str, + appearance: AgentAppearance, + *, + expected_revision: int, + ) -> AgentDraft: + snapshot = self.studio.codex_manifests.load(agent_id) + current = self._project(snapshot) + if current.metadata.revision != expected_revision: + raise StudioError( + "AGENT_REVISION_CONFLICT", + "Agent 已被其他操作更新", + status_code=409, + field="metadata.revision", + details={"expected": expected_revision, "actual": current.metadata.revision}, + ) + updated = current.model_copy(deep=True) + updated.metadata.revision += 1 + updated.metadata.appearance = appearance + self.drafts.save(updated) + return self._project(snapshot, current=updated) + + @staticmethod + def ensure_bindings_supported(spec: AgentSpec) -> None: + bindings = spec.bindings + if bindings.tools or spec.capabilities.tools: + raise StudioError( + "TOOL_RUNTIME_INCOMPATIBLE", + "Codex Runtime 当前只使用其原生工具,不能绑定 ksadk Tool", + status_code=422, + field="spec.bindings.tools", + details={"runtimeType": "codex"}, + ) + # codex 支持 streamable-http MCP(通过 config_overrides 注入 mcp_servers)。 + + def _skill_resource_ids(self, spec: AgentSpec) -> list[str]: + bindings = spec.bindings + ids = [b.resource_id for b in bindings.skills] + ids.extend(spec.capabilities.skills or []) + seen: set[str] = set() + ordered: list[str] = [] + for sid in ids: + if sid and sid not in seen: + seen.add(sid) + ordered.append(sid) + return ordered + + def _mcp_server_configs(self, spec: AgentSpec) -> list[dict[str, Any]]: + """Resolve bound MCP resource ids to codex streamable-http MCP configs.""" + bindings = spec.bindings + ids = [b.resource_id for b in bindings.mcp_servers] + ids.extend(spec.capabilities.mcp_servers or []) + seen: set[str] = set() + ordered: list[dict[str, Any]] = [] + catalog = getattr(self.studio, "catalog", None) + if catalog is None: + return ordered + try: + resources = catalog.list(limit=200) + except Exception: + return ordered + mcp_by_id = {item.resource_id: item for item in resources if item.kind == "mcp"} + for rid in ids: + if rid in seen: + continue + seen.add(rid) + descriptor = mcp_by_id.get(rid) + if descriptor is None: + continue + contract = descriptor.contract or {} + url = str( + contract.get("endpointUrl") + or contract.get("endpoint_url") + or contract.get("url") + or "" + ).strip() + if not url: + continue + env_refs = contract.get("envRefs") or {} + env_key = "" + for ref in env_refs.values(): + if isinstance(ref, str) and ref.startswith("env://"): + env_key = ref.removeprefix("env://") + break + entry: dict[str, Any] = { + "name": descriptor.name, + "url": url, + } + if env_key: + entry["env_key"] = env_key + ordered.append(entry) + return ordered + + def delete(self, agent_id: str, *, purge: bool = False) -> None: + self.studio.codex_manifests.load(agent_id) + running = [ + run.id + for run in self.studio.event_store.list_runs(agent_id=agent_id) + if run.status in {RunStatus.CREATED, RunStatus.RUNNING} + ] + if running: + raise StudioError( + "AGENT_RUN_ACTIVE", + "Agent 仍有运行中的任务,请等待运行结束后再删除", + status_code=409, + details={"agentId": agent_id, "runIds": running}, + ) + trash_directory = None + if not purge: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + trash_directory = self.studio.workspace.resolve( + Path(".agentkit/trash/agents") / f"{agent_id}-{timestamp}" + ) + trash_directory.mkdir(parents=True, exist_ok=False) + self.studio.event_store.delete_agent( + agent_id, + purge=purge, + trash_directory=trash_directory, + ) + self.studio.codex_builds.delete_for_agent( + agent_id, + purge=purge, + trash_directory=trash_directory, + ) + self.drafts.delete( + agent_id, + purge=purge, + trash_directory=trash_directory, + ) + self.studio.codex_manifests.delete( + agent_id, + purge=purge, + trash_directory=trash_directory, + ) + + def detail(self, agent_id: str | None = None) -> dict: + snapshot = self.studio.codex_manifests.load(agent_id) + return { + "draft": self._project(snapshot), + "builds": [self.build_view(item) for item in self._builds(snapshot.manifest.name)], + "validation": {"valid": True, "level": "build", "diagnostics": []}, + "manifestSha256": snapshot.manifest_sha256, + "sourcePath": self.studio.workspace.relative(snapshot.source_path), + } + + @staticmethod + def build_view(record: CodexBuildRecord) -> dict: + return { + "id": record.id, + "agentId": record.agent_name, + "sourceRevision": record.source_revision, + "status": record.status, + "resolvedDigest": record.manifest_sha256, + "bundleDigest": f"sha256:{record.manifest_sha256}", + "artifactPath": record.artifact_path, + "diagnostics": [], + "createdAt": record.created_at, + "completedAt": record.created_at, + "manifestSha256": record.manifest_sha256, + "runtimeLock": record.runtime_lock, + "runtimeName": record.runtime_name, + "runtimeVersion": record.runtime_version, + "proxyMode": record.proxy_mode, + } + + def submit_build( + self, + *, + idempotency_key: str, + agent_id: str | None = None, + ) -> Operation: + resolved_id = self.studio.codex_manifests.load(agent_id).manifest.name + revision = self._project(self.studio.codex_manifests.load(resolved_id)).metadata.revision + + async def runner(): + return await asyncio.to_thread( + self.studio.codex_builder.build, + resolved_id, + source_revision=revision, + ) + + return cast( + Operation, + self.studio.operations.submit( + kind=OperationKind.BUILD, + resource_id=resolved_id, + idempotency_key=idempotency_key, + runner=runner, + ), + ) + + def submit_run( + self, + build_id: str, + user_input: str, + *, + session_id: str | None, + model: str | None, + idempotency_key: str, + on_event: Callable[[RunEvent], None] | None, + sandbox: str | None = None, + approval_mode: str | None = None, + collaboration_mode: str | None = None, + goal_objective: str | None = None, + runtime_input: Any = None, + ) -> Operation: + async def runner(): + return await self.studio.run_build( + build_id, + user_input, + session_id, + model=model, + sandbox=sandbox, + approval_mode=approval_mode, + collaboration_mode=collaboration_mode, + goal_objective=goal_objective, + runtime_input=runtime_input, + on_event=on_event, + ) + + return cast( + Operation, + self.studio.operations.submit( + kind=OperationKind.RUN, + resource_id=build_id, + idempotency_key=idempotency_key, + runner=runner, + ), + ) + + def _project( + self, + snapshot: CodexManifestSnapshot, + *, + current: AgentDraft | None = None, + ) -> AgentDraft: + manifest = snapshot.manifest + saved = current or self.drafts.get(manifest.name) + bindings = self._model_bindings(manifest) + if saved is not None: + draft = saved.model_copy(deep=True) + draft.spec.runtime = RuntimeRef( + type="codex", + version=manifest.runtime.version, + ) + draft.spec.instructions = Instructions(system=manifest.prompt, task="") + draft.spec.bindings.model_profile_id = bindings[0] + draft.spec.bindings.model_profile_ids = bindings[1] + draft.metadata.labels.update(self._labels(manifest)) + return draft + default_profile, profiles = bindings + return AgentDraft( + metadata=AgentMetadata( + id=manifest.name, + name=manifest.name, + labels=self._labels(manifest), + ), + spec=AgentSpec( + description="由 agentengine.yaml 管理的 Codex Agent", + runtime=RuntimeRef(type="codex", version=manifest.runtime.version), + instructions=Instructions(system=manifest.prompt), + bindings=AgentBindings( + model_profile_id=default_profile, + model_profile_ids=profiles, + ), + ), + ) + + def _manifest( + self, + agent_id: str, + spec: AgentSpec, + *, + current: CodexAgentManifest | None = None, + ) -> CodexAgentManifest: + model = self._model_name(spec, agent_id=agent_id) + models = self._model_names(spec, default_model=model, agent_id=agent_id) + prompt = spec.instructions.system.strip() + if spec.instructions.task.strip(): + prompt = f"{prompt}\n\n任务约束:\n{spec.instructions.task.strip()}" + skill_ids = self._skill_resource_ids(spec) + mcp_servers = self._mcp_server_configs(spec) + return CodexAgentManifest( + name=agent_id, + version=current.version if current is not None else "1.0.0", + runtime=CodexRuntimeRef(version=self._runtime_version(spec, current=current)), + model=model, + models=models if len(models) > 1 else None, + prompt=prompt, + skills=skill_ids or None, + mcp_servers=mcp_servers or None, + sandbox=spec.execution.sandbox, + approval_mode=spec.execution.approval_mode, + ) + + @staticmethod + def _runtime_version( + spec: AgentSpec, + *, + current: CodexAgentManifest | None = None, + ) -> str: + if spec.runtime is not None and spec.runtime.version: + return spec.runtime.version + return current.runtime.version if current is not None else "0.144.4" + + def _model_name(self, spec: AgentSpec, *, agent_id: str | None = None) -> str: + resolved = self.studio.catalog.resolve_model(spec.bindings) + if resolved is not None: + return str(resolved.model) + if spec.model is not None: + return spec.model.model + if agent_id and self.studio.codex_manifests.exists(agent_id): + return str(self.studio.codex_manifests.load(agent_id).manifest.model) + configured = os.environ.get("OPENAI_MODEL_NAME", "").strip() + return configured or "glm-5.1" + + def _model_names( + self, + spec: AgentSpec, + *, + default_model: str, + agent_id: str | None, + ) -> builtins.list[str]: + names = [item.model for item in self.studio.catalog.resolve_models(spec.bindings)] + if not names and agent_id and self.studio.codex_manifests.exists(agent_id): + names = builtins.list( + self.studio.codex_manifests.load(agent_id).manifest.allowed_models + ) + if not names and spec.model is not None: + names = [spec.model.model] + names = [default_model, *[name for name in names if name != default_model]] + return builtins.list(dict.fromkeys(names)) + + def _model_bindings( + self, + manifest: CodexAgentManifest, + ) -> tuple[str | None, builtins.list[str]]: + resources: dict[str, str] = {} + for descriptor in self.studio.catalog.list(kind="model", limit=500): + try: + model_spec = ModelSpec.model_validate(descriptor.contract) + except ValueError: + continue + resources.setdefault(model_spec.model, descriptor.resource_id) + default = resources.get(manifest.model) + profiles = [resources[model] for model in manifest.allowed_models if model in resources] + return default, profiles if default in profiles else [] + + @staticmethod + def _labels(manifest: CodexAgentManifest) -> dict[str, str]: + return { + "agentkit.ksyun.com/template": "blank", + "agentkit.ksyun.com/framework": "codex", + "agentkit.ksyun.com/artifact-type": "ManagedRuntime", + "agentkit.ksyun.com/model": manifest.model, + "agentkit.ksyun.com/models": ",".join(manifest.allowed_models), + "agentkit.ksyun.com/runtime-version": manifest.runtime.version, + "agentkit.ksyun.com/manifest-version": manifest.version, + } + + def _builds(self, agent_id: str) -> builtins.list[CodexBuildRecord]: + return [item for item in self.studio.codex_builds.list() if item.agent_name == agent_id] + + +__all__ = ["CodexAgentService", "CodexDraftRepository"] diff --git a/ksadk/studio/codex_builder.py b/ksadk/studio/codex_builder.py new file mode 100644 index 00000000..900b9f98 --- /dev/null +++ b/ksadk/studio/codex_builder.py @@ -0,0 +1,324 @@ +"""Codex Studio 的本地 ManagedRuntime 审计构建。""" + +from __future__ import annotations + +import json +import os +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Literal, cast + +from pydantic import ValidationError + +from ksadk.builders.managed_runtime_builder import ManagedRuntimeBuilder +from ksadk.managed_runtime import ( + ResolvedRuntime, + validate_installed_runtime, + validate_runtime_binary, +) +from ksadk.studio.codex_manifest import CodexManifestRepository +from ksadk.studio.contracts import ContractModel, ModelSpec +from ksadk.studio.errors import StudioError, not_found +from ksadk.studio.workspace import Workspace +from ksadk.version import VERSION as SDK_VERSION + +RuntimeInspector = Callable[[ResolvedRuntime], tuple[str, str, str]] + + +class CodexBuildRecord(ContractModel): + id: str + status: Literal["SUCCEEDED"] = "SUCCEEDED" + agent_name: str + agent_version: str + source_revision: int = 1 + artifact_path: str + manifest_sha256: str + runtime_name: Literal["codex"] = "codex" + runtime_version: str + sdk_version: str + cli_version: str + proxy_mode: Literal["forced", "auto", "direct"] + runtime_lock: dict + # ``None`` identifies legacy records that predate connection snapshots. + # New builds always persist a mapping (possibly empty), so run resolution + # never consults mutable Catalog state after the build has been created. + model_profiles: dict[str, dict[str, Any]] | None = None + created_at: datetime + + +class CodexBuildRepository: + def __init__(self, workspace: Workspace) -> None: + self.workspace = workspace + + def _path(self, build_id: str) -> Path: + return self.workspace.resolve(Path(".agentkit/codex-builds") / f"{build_id}.json") + + def save(self, record: CodexBuildRecord) -> CodexBuildRecord: + payload = record.model_dump( + by_alias=True, + exclude_none=True, + mode="json", + ) + self.workspace.atomic_write_text( + self._path(record.id), + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + ) + return record + + def get(self, build_id: str) -> CodexBuildRecord: + path = self._path(build_id) + if not path.is_file(): + raise not_found("codex build", build_id) + try: + return cast( + CodexBuildRecord, + CodexBuildRecord.model_validate_json(path.read_text(encoding="utf-8")), + ) + except (OSError, ValidationError) as exc: + raise StudioError( + "CODEX_BUILD_RECORD_INVALID", + "Codex Build 记录损坏", + status_code=500, + details={"id": build_id}, + ) from exc + + def list(self) -> list[CodexBuildRecord]: + directory = self.workspace.resolve(".agentkit/codex-builds") + records: list[CodexBuildRecord] = [] + for path in directory.glob("build_*.json"): + try: + records.append( + CodexBuildRecord.model_validate_json(path.read_text(encoding="utf-8")) + ) + except (OSError, ValidationError): + continue + return sorted(records, key=lambda item: item.created_at, reverse=True) + + def delete_for_agent( + self, + agent_id: str, + *, + purge: bool, + trash_directory: Path | None = None, + ) -> int: + records = [item for item in self.list() if item.agent_name == agent_id] + artifacts = { + self.workspace.resolve(item.artifact_path) for item in records if item.artifact_path + } + for artifact in artifacts: + self._remove_file( + artifact, + purge=purge, + destination=( + None + if trash_directory is None + else trash_directory / "artifacts" / artifact.name + ), + ) + for record in records: + path = self._path(record.id) + self._remove_file( + path, + purge=purge, + destination=( + None if trash_directory is None else trash_directory / "builds" / path.name + ), + ) + return len(records) + + def _remove_file( + self, + source: Path, + *, + purge: bool, + destination: Path | None, + ) -> None: + if not source.is_file(): + return + if purge: + source.unlink() + return + if destination is None: + raise ValueError("recoverable deletion requires a trash destination") + target = self.workspace.resolve(destination) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(target)) + + +def current_proxy_mode() -> Literal["forced", "auto", "direct"]: + override = os.environ.get("KSADK_CODEX_USE_PROXY") + if override == "1": + return "forced" + if override == "0": + return "direct" + return "auto" + + +def _inspect_runtime(runtime: ResolvedRuntime) -> tuple[str, str, str]: + installed = validate_installed_runtime(runtime) + cli = validate_runtime_binary(runtime) + return SDK_VERSION, installed, cli + + +class CodexStudioBuilder: + def __init__( + self, + workspace: Workspace, + *, + manifest_repository: CodexManifestRepository | None = None, + build_repository: CodexBuildRepository | None = None, + runtime_inspector: RuntimeInspector = _inspect_runtime, + resource_catalog: Any = None, + draft_repository: Any = None, + ) -> None: + self.workspace = workspace + self.manifests = manifest_repository or CodexManifestRepository(workspace) + self.repository = build_repository or CodexBuildRepository(workspace) + self.runtime_inspector = runtime_inspector + self.catalog = resource_catalog + self.drafts = draft_repository + + def build( + self, + agent_id: str | None = None, + *, + source_revision: int = 1, + ) -> CodexBuildRecord: + snapshot = self.manifests.load(agent_id) + model_profiles = self._model_profile_snapshot( + snapshot.manifest.name, + allowed_models=snapshot.manifest.allowed_models, + ) + build_id = self._build_id(snapshot.manifest_sha256, model_profiles) + try: + existing = self.repository.get(build_id) + except StudioError as exc: + if exc.status_code != 404: + raise + else: + return existing + + runtime = ResolvedRuntime( + name=snapshot.manifest.runtime.name, + version=snapshot.manifest.runtime.version, + source="manifest", + ) + sdk_version, installed_runtime, cli_version = self.runtime_inspector(runtime) + if installed_runtime != runtime.version: + raise StudioError( + "CODEX_RUNTIME_VERSION_MISMATCH", + "本地 Codex Runtime 与 agentengine.yaml 锁定版本不一致", + status_code=422, + details={"expected": runtime.version, "installed": installed_runtime}, + ) + + result = ManagedRuntimeBuilder( + self.workspace.root, + config=snapshot.manifest.model_dump(mode="python", exclude_none=True), + runtime_version=runtime.version, + ).build() + if not result.success or result.artifact_path is None: + raise StudioError( + "CODEX_BUILD_FAILED", + result.error_message or "Codex ManagedRuntime 构建失败", + status_code=422, + ) + lock = self._runtime_lock(result.artifact_path) + lock_sha = str(lock.get("manifest_sha256") or "") + if lock_sha != snapshot.manifest_sha256: + raise StudioError( + "CODEX_BUILD_DIGEST_MISMATCH", + "构建产物与当前 agentengine.yaml 摘要不一致", + status_code=500, + ) + record = CodexBuildRecord( + id=build_id, + agent_name=snapshot.manifest.name, + agent_version=snapshot.manifest.version, + source_revision=source_revision, + artifact_path=self.workspace.relative(result.artifact_path), + manifest_sha256=snapshot.manifest_sha256, + runtime_version=runtime.version, + sdk_version=sdk_version, + cli_version=cli_version, + proxy_mode=current_proxy_mode(), + runtime_lock=lock, + model_profiles=model_profiles, + created_at=datetime.now(timezone.utc), + ) + return self.repository.save(record) + + def is_current(self, record: CodexBuildRecord) -> bool: + snapshot = self.manifests.load(record.agent_name) + if record.manifest_sha256 != snapshot.manifest_sha256: + return False + if record.model_profiles is None: + return True + return record.model_profiles == self._model_profile_snapshot( + snapshot.manifest.name, + allowed_models=snapshot.manifest.allowed_models, + ) + + @staticmethod + def _build_id( + manifest_sha256: str, + model_profiles: dict[str, dict[str, Any]], + ) -> str: + if not model_profiles: + return f"build_{manifest_sha256[:20]}" + fingerprint = json.dumps( + model_profiles, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + import hashlib + + digest = hashlib.sha256(f"{manifest_sha256}\n{fingerprint}".encode()).hexdigest() + return f"build_{digest[:20]}" + + def _model_profile_snapshot( + self, + agent_id: str, + *, + allowed_models: tuple[str, ...], + ) -> dict[str, dict[str, Any]]: + if self.catalog is None or self.drafts is None: + return {} + draft = self.drafts.get(agent_id) + if draft is None: + return {} + bindings = draft.spec.bindings + resource_ids = list(getattr(bindings, "model_profile_ids", []) or []) + default_id = getattr(bindings, "model_profile_id", None) + if not resource_ids and default_id: + resource_ids = [default_id] + profiles: dict[str, dict[str, Any]] = {} + for resource_id in resource_ids: + descriptor = self.catalog.get(resource_id) + profile = ModelSpec.model_validate(descriptor.contract) + if profile.model not in allowed_models or profile.model in profiles: + continue + profiles[profile.model] = profile.model_dump( + by_alias=True, + exclude_defaults=True, + exclude_none=True, + mode="json", + ) + return profiles + + @staticmethod + def _runtime_lock(artifact_path: Path) -> dict: + import zipfile + + with zipfile.ZipFile(artifact_path) as archive: + return cast(dict, json.loads(archive.read("runtime-lock.json"))) + + +__all__ = [ + "CodexBuildRecord", + "CodexBuildRepository", + "CodexStudioBuilder", + "current_proxy_mode", +] diff --git a/ksadk/studio/codex_manifest.py b/ksadk/studio/codex_manifest.py new file mode 100644 index 00000000..d635baf1 --- /dev/null +++ b/ksadk/studio/codex_manifest.py @@ -0,0 +1,278 @@ +"""Codex Studio 的单一 ``agentengine.yaml`` 合同与文件仓储。""" + +from __future__ import annotations + +import hashlib +import re +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, cast + +import yaml # type: ignore[import-untyped] +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + +from ksadk.builders.managed_runtime_builder import serialize_managed_runtime_manifest +from ksadk.studio.errors import StudioError, not_found +from ksadk.studio.workspace import Workspace + + +class CodexRuntimeRef(BaseModel): + """平台托管的 Codex runtime 锁定信息。""" + + model_config = ConfigDict(extra="forbid") + + name: Literal["codex"] = "codex" + version: str = Field(min_length=1, max_length=64) + + +class CodexAgentManifest(BaseModel): + """本地演示允许进入 ``agentengine.yaml`` 的完整字段集合。""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(pattern=r"^[a-z][a-z0-9-]{2,62}$") + version: str = Field(min_length=1, max_length=64) + framework: Literal["codex"] = "codex" + artifact_type: Literal["ManagedRuntime"] = "ManagedRuntime" + runtime: CodexRuntimeRef + model: str = Field(min_length=1, max_length=256) + models: list[str] | None = None + prompt: str = Field(min_length=1, max_length=32768) + skills: list[str] | None = None + mcp_servers: list[dict[str, Any]] | None = None + sandbox: str | None = None + approval_mode: str | None = None + + @model_validator(mode="after") + def validate_models(self) -> "CodexAgentManifest": + if self.models is None: + return self + normalized: list[str] = [] + for value in self.models: + model = str(value).strip() + if not model or len(model) > 256: + raise ValueError("models 中的模型名称长度必须为 1..256") + if model in normalized: + raise ValueError("models 不能包含重复模型") + normalized.append(model) + if not normalized: + raise ValueError("models 至少包含一个模型") + if self.model not in normalized: + raise ValueError("默认模型 model 必须包含在 models 中") + self.models = normalized + if self.skills is not None: + seen: set[str] = set() + deduped: list[str] = [] + for sid in self.skills: + sid = str(sid).strip() + if not sid: + raise ValueError("skills 不能包含空值") + if sid in seen: + raise ValueError("skills 不能包含重复资源") + seen.add(sid) + deduped.append(sid) + self.skills = deduped + if self.mcp_servers is not None: + mcp_seen: set[str] = set() + mcp_deduped: list[dict[str, Any]] = [] + for server in self.mcp_servers: + if not isinstance(server, dict): + raise ValueError("mcp_servers 必须是对象列表") + name = str(server.get("name") or "").strip() + url = str(server.get("url") or "").strip() + if not name: + raise ValueError("mcp_servers 每项必须有 name") + if not url: + raise ValueError("mcp_servers 每项必须有 url") + if name in mcp_seen: + raise ValueError("mcp_servers 不能包含重复 name") + mcp_seen.add(name) + mcp_deduped.append(server) + self.mcp_servers = mcp_deduped + return self + + @property + def allowed_models(self) -> tuple[str, ...]: + return tuple(self.models or [self.model]) + + +def normalized_manifest_bytes(manifest: CodexAgentManifest) -> bytes: + """生成构建、SHA 和磁盘写入共同使用的规范化 YAML。""" + + payload = manifest.model_dump(mode="python", exclude_none=True) + return serialize_managed_runtime_manifest(payload) + + +@dataclass(frozen=True) +class CodexManifestSnapshot: + manifest: CodexAgentManifest + manifest_sha256: str + source_path: Path + source_bytes: bytes + + +_AGENT_NAME = re.compile(r"^[a-z][a-z0-9-]{2,62}$") + + +class CodexManifestRepository: + """每个本地 Agent 一份 YAML,首个 Agent 兼容工作区根路径。""" + + def __init__(self, workspace: Workspace) -> None: + self.workspace = workspace + self.path = workspace.resolve("agentengine.yaml") + self.agents_path = workspace.resolve("agents") + + def exists(self, agent_id: str | None = None) -> bool: + if agent_id is None: + return self.path.is_file() + try: + self.load(agent_id) + except StudioError as exc: + if exc.status_code == 404: + return False + raise + return True + + def load(self, agent_id: str | None = None) -> CodexManifestSnapshot: + if agent_id is None: + return self._load_path(self.path) + self._validate_agent_id(agent_id) + if self.path.is_file(): + root = self._load_path(self.path) + if root.manifest.name == agent_id: + return root + path = self._agent_path(agent_id) + snapshot = self._load_path(path, agent_id=agent_id) + if snapshot.manifest.name != agent_id: + raise StudioError( + "CODEX_MANIFEST_ID_MISMATCH", + "Agent 目录与 agentengine.yaml 中的 name 不一致", + status_code=422, + details={"agentId": agent_id, "manifestName": snapshot.manifest.name}, + ) + return snapshot + + def list(self) -> list[CodexManifestSnapshot]: + snapshots: list[CodexManifestSnapshot] = [] + seen: set[str] = set() + if self.path.is_file(): + root = self._load_path(self.path) + snapshots.append(root) + seen.add(root.manifest.name) + for path in sorted(self.agents_path.glob("*/agentengine.yaml")): + try: + snapshot = self._load_path(path) + except StudioError: + continue + if snapshot.manifest.name in seen: + continue + snapshots.append(snapshot) + seen.add(snapshot.manifest.name) + return snapshots + + def _load_path( + self, + path: Path, + *, + agent_id: str | None = None, + ) -> CodexManifestSnapshot: + if not path.is_file(): + if agent_id is not None: + raise not_found("agent", agent_id) + raise StudioError( + "CODEX_MANIFEST_NOT_FOUND", + "当前工作区还没有 agentengine.yaml", + status_code=404, + ) + try: + payload = yaml.safe_load(path.read_text(encoding="utf-8-sig")) + manifest = cast( + CodexAgentManifest, + CodexAgentManifest.model_validate(payload), + ) + except (OSError, yaml.YAMLError, ValidationError) as exc: + raise StudioError( + "CODEX_MANIFEST_INVALID", + "agentengine.yaml 不符合 Codex ManagedRuntime 合同", + status_code=422, + details={"reason": str(exc)}, + ) from exc + source = normalized_manifest_bytes(manifest) + return CodexManifestSnapshot( + manifest=manifest, + manifest_sha256=hashlib.sha256(source).hexdigest(), + source_path=path, + source_bytes=source, + ) + + def save(self, manifest: CodexAgentManifest) -> CodexManifestSnapshot: + source = normalized_manifest_bytes(manifest) + path = self._save_path(manifest.name) + self.workspace.atomic_write_text(path, source.decode("utf-8")) + return CodexManifestSnapshot( + manifest=manifest, + manifest_sha256=hashlib.sha256(source).hexdigest(), + source_path=path, + source_bytes=source, + ) + + def delete( + self, + agent_id: str, + *, + purge: bool, + trash_directory: Path | None = None, + ) -> None: + """Remove one Agent source without ever deleting the workspace root.""" + + snapshot = self.load(agent_id) + source = snapshot.source_path + if source == self.path: + if purge: + source.unlink() + return + if trash_directory is None: + raise ValueError("recoverable deletion requires a trash directory") + destination = self.workspace.resolve(trash_directory / "source/agentengine.yaml") + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(destination)) + return + + agent_directory = source.parent + if purge: + shutil.rmtree(agent_directory) + return + if trash_directory is None: + raise ValueError("recoverable deletion requires a trash directory") + destination = self.workspace.resolve( + trash_directory / "source/agents" / agent_id + ) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(agent_directory), str(destination)) + + def _save_path(self, agent_id: str) -> Path: + self._validate_agent_id(agent_id) + if not self.path.is_file(): + return self.path + if self._load_path(self.path).manifest.name == agent_id: + return self.path + return self._agent_path(agent_id) + + def _agent_path(self, agent_id: str) -> Path: + self._validate_agent_id(agent_id) + return self.workspace.resolve(Path("agents") / agent_id / "agentengine.yaml") + + @staticmethod + def _validate_agent_id(agent_id: str) -> None: + if not _AGENT_NAME.fullmatch(agent_id): + raise not_found("agent", agent_id) + + +__all__ = [ + "CodexAgentManifest", + "CodexManifestRepository", + "CodexManifestSnapshot", + "CodexRuntimeRef", + "normalized_manifest_bytes", +] diff --git a/ksadk/studio/codex_run.py b/ksadk/studio/codex_run.py new file mode 100644 index 00000000..f3306262 --- /dev/null +++ b/ksadk/studio/codex_run.py @@ -0,0 +1,352 @@ +"""Resolve an immutable Codex build into a canonical StudioRunSpec.""" + +from __future__ import annotations + +import os +import zipfile +from typing import Any, cast + +import yaml # type: ignore[import-untyped] + +from ksadk.configs import ModelConfig +from ksadk.runtime import RuntimeLaunchContext +from ksadk.studio.codex_builder import CodexBuildRepository +from ksadk.studio.codex_manifest import CodexAgentManifest, CodexManifestRepository +from ksadk.studio.contracts import ModelSpec +from ksadk.studio.errors import StudioError +from ksadk.studio.run_service import StudioRunSpec +from ksadk.studio.workspace import Workspace +from ksadk.tools.gateway import normalize_tool_approval_mode + + +class CodexRunSpecResolver: + """Validate a build snapshot without creating another Runtime abstraction.""" + + def __init__( + self, + workspace: Workspace, + *, + build_repository: CodexBuildRepository | None = None, + manifest_repository: CodexManifestRepository | None = None, + credential_resolver: Any = None, + resource_catalog: Any = None, + draft_repository: Any = None, + ) -> None: + self.workspace = workspace + self.builds = build_repository or CodexBuildRepository(workspace) + self.manifests = manifest_repository or CodexManifestRepository(workspace) + self.credentials = credential_resolver + self.catalog = resource_catalog + self.drafts = draft_repository + + def resolve( + self, + build_id: str, + *, + model: str | None = None, + sandbox: str | None = None, + approval_mode: str | None = None, + ) -> StudioRunSpec: + build = self.builds.get(build_id) + current = self.manifests.load(build.agent_name) + if build.manifest_sha256 != current.manifest_sha256: + raise StudioError( + "CODEX_BUILD_STALE", + "agentengine.yaml 已修改,请重新构建后再运行", + status_code=409, + details={ + "buildManifestSha256": build.manifest_sha256, + "currentManifestSha256": current.manifest_sha256, + }, + ) + manifest = self._load_build_manifest(build.artifact_path) + selected_model = self._select_model(manifest, model) + project_dir = self.workspace.root.resolve() + skills = self._skill_inputs(manifest) + codex_overrides = self._mcp_overrides(manifest) + approval_profile = normalize_tool_approval_mode(approval_mode) if approval_mode else "" + sandbox, approval = self._resolve_sandbox( + manifest, + override=sandbox, + approval_mode=approval_profile or None, + ) + if sandbox == "workspace-write": + # workspace-write 默认断网;MCP/搜索类工具需要显式放行网络 + codex_overrides = [*codex_overrides, "sandbox_workspace_write.network_access=true"] + launch_config: dict[str, Any] = { + "sandbox_read_only": sandbox == "read-only", + "sandbox": sandbox, + "approval_mode": approval, + } + if codex_overrides: + launch_config["codex_overrides"] = codex_overrides + runtime_env = { + **self._resolve_model_env(build, manifest, selected_model), + **self._resolve_mcp_env(manifest), + } + if runtime_env: + launch_config["env"] = runtime_env + request_config: dict[str, Any] = { + "base_instructions": manifest.prompt, + "cwd": str(project_dir), + "skills": skills, + "sandbox_read_only": sandbox == "read-only", + "sandbox": sandbox, + "approval_mode": approval, + "summary": "auto", + # Studio sessions resume the same native Codex thread across turns. + "ephemeral": False, + } + if approval_profile: + request_config["tool_approval_mode"] = approval_profile + return StudioRunSpec( + launch_context=RuntimeLaunchContext( + runtime_type="codex", + project_dir=project_dir, + config=launch_config, + ), + build_id=build.id, + agent_id=manifest.name, + model=selected_model, + request_config=request_config, + manifest_sha256=build.manifest_sha256, + ) + + def _resolve_model_env( + self, + build: Any, + manifest: CodexAgentManifest, + selected_model: str, + ) -> dict[str, str]: + resolver = self.credentials + if resolver is None: + from ksadk.studio.model_client import CredentialResolver + + resolver = CredentialResolver(self.workspace) + self.credentials = resolver + profile = self._resolve_model_profile(build, manifest, selected_model) + credential_ref = profile.credential_ref if profile else "env://OPENAI_API_KEY" + try: + credential = resolver.resolve(credential_ref) + except StudioError: + return {} + base_url = ( + self._model_base_url(profile) + if profile is not None + else ModelConfig().api_base.rstrip("/") + ) + return { + "OPENAI_API_KEY": credential, + "OPENAI_BASE_URL": base_url, + "OPENAI_API_BASE": base_url, + "OPENAI_MODEL_NAME": selected_model, + } + + def _resolve_model_profile( + self, + build: Any, + manifest: CodexAgentManifest, + selected_model: str, + ) -> ModelSpec | None: + # New build records own an immutable connection snapshot. An empty + # mapping explicitly means this build uses the process/workspace + # default connection; only legacy records may consult live Catalog + # state for backward compatibility. + snapshots = getattr(build, "model_profiles", None) + if snapshots is not None: + payload = snapshots.get(selected_model) + return ModelSpec.model_validate(payload) if payload is not None else None + + if self.catalog is None: + from ksadk.studio.resource_catalog import LocalResourceCatalog + + self.catalog = LocalResourceCatalog(self.workspace) + if self.drafts is None: + from ksadk.studio.codex_agent_service import CodexDraftRepository + + self.drafts = CodexDraftRepository(self.workspace) + + draft = self.drafts.get(manifest.name) + if draft is not None: + bindings = draft.spec.bindings + resource_ids = list(bindings.model_profile_ids) + if not resource_ids and bindings.model_profile_id: + resource_ids = [bindings.model_profile_id] + for resource_id in resource_ids: + try: + descriptor = self.catalog.get(resource_id) + profile = ModelSpec.model_validate(descriptor.contract) + except (StudioError, ValueError): + continue + if profile.model == selected_model: + return profile + + matches: list[ModelSpec] = [] + for descriptor in self.catalog.list(kind="model", limit=500): + try: + profile = ModelSpec.model_validate(descriptor.contract) + except ValueError: + continue + if profile.model == selected_model: + matches.append(profile) + return matches[0] if len(matches) == 1 else None + + @staticmethod + def _model_base_url(profile: ModelSpec) -> str: + raw = str(profile.base_url or profile.endpoint_url or "").rstrip("/") + for suffix in ("/chat/completions", "/responses"): + if raw.endswith(suffix): + return raw[: -len(suffix)] + return raw + + @staticmethod + def _mcp_overrides(manifest: CodexAgentManifest) -> list[str]: + """Translate bound MCP servers into codex --config overrides.""" + overrides: list[str] = [] + for server in manifest.mcp_servers or []: + name = str(server.get("name") or "").strip() + url = str(server.get("url") or "").strip() + if not name or not url: + continue + overrides.append(f"mcp_servers.{name}.url={url}") + env_key = str(server.get("env_key") or "").strip() + if env_key: + overrides.append(f"mcp_servers.{name}.bearer_token_env_var={env_key}") + return overrides + + def _resolve_mcp_env(self, manifest: CodexAgentManifest) -> dict[str, str]: + """把 manifest 中 MCP 的 env_key 解析为真实值,注入 codex 子进程环境。 + + codex app-server 的 bearer_token_env_var 直接读子进程环境变量,不经过 + Studio 的凭证解析链(session → 工作区 secrets.env → 环境变量),必须在 + 启动前完成解析。缺凭证时快速失败并指出具体引用。 + """ + env: dict[str, str] = {} + for server in manifest.mcp_servers or []: + env_key = str(server.get("env_key") or "").strip() + if not env_key: + continue + if env_key in env: + continue + resolver = self.credentials + if resolver is None: + from ksadk.studio.model_client import CredentialResolver + + resolver = CredentialResolver(self.workspace) + self.credentials = resolver + try: + env[env_key] = resolver.resolve(f"env://{env_key}") + except Exception as exc: + raise StudioError( + "MCP_CREDENTIAL_MISSING", + f"MCP Server「{server.get('name')}」需要凭证 {env_key}," + "请先在 MCP 页连接时保存该环境变量的值", + status_code=422, + details={ + "server": str(server.get("name") or ""), + "reference": f"env://{env_key}", + }, + ) from exc + return env + + @staticmethod + def _resolve_sandbox( + manifest: CodexAgentManifest, + override: str | None = None, + approval_mode: str | None = None, + ) -> tuple[str, str]: + """Resolve (sandbox, approval_mode): per-run override > manifest > env > default. + + UI 4 档: read_only / workspace_write / workspace_write_auto / full_access + """ + presets = { + "read_only": ("read-only", "deny_all"), + "read-only": ("read-only", "deny_all"), + "workspace_write": ("workspace-write", "deny_all"), + "workspace-write": ("workspace-write", "deny_all"), + "workspace_write_auto": ("workspace-write", "auto_review"), + "workspace-write-auto": ("workspace-write", "auto_review"), + "full_access": ("full-access", "deny_all"), + "full-access": ("full-access", "deny_all"), + } + approval_presets = { + "ask": ("workspace-write", "manual"), + "risk": ("workspace-write", "auto_review"), + "full": ("full-access", "deny_all"), + } + if approval_mode: + return approval_presets[normalize_tool_approval_mode(approval_mode)] + raw = ( + (override or os.environ.get("KSADK_CODEX_SANDBOX") or manifest.sandbox or "read_only") + .strip() + .lower() + ) + return presets.get(raw, ("read-only", "deny_all")) + + def _skill_inputs(self, manifest: CodexAgentManifest) -> list[dict[str, str]]: + """Resolve bound skill resource ids to codex SkillInput wire dicts.""" + if not manifest.skills: + return [] + skills_root = self.workspace.resolve("capabilities/skills") + inputs: list[dict[str, str]] = [] + for sid in manifest.skills: + name = self._skill_name_from_id(sid) + if not name: + continue + skill_dir = skills_root / name + if not skill_dir.is_dir(): + continue + inputs.append({"name": name, "path": str(skill_dir.resolve())}) + return inputs + + @staticmethod + def _skill_name_from_id(resource_id: str) -> str: + parts = str(resource_id).split(":") + if len(parts) >= 4 and parts[0] == "skill": + return parts[2] + if len(parts) == 2: + return parts[1] + return "" + + @staticmethod + def _select_model(manifest: CodexAgentManifest, requested: str | None) -> str: + selected = str(requested or manifest.model).strip() + if selected not in manifest.allowed_models: + raise StudioError( + "MODEL_NOT_BOUND", + "请求模型未绑定到当前 Agent Build", + status_code=422, + details={ + "model": selected, + "allowedModels": list(manifest.allowed_models), + }, + ) + environment_allowlist = { + item.strip() + for item in os.environ.get("AGENTENGINE_MODEL_ALLOWLIST", "").split(",") + if item.strip() + } + if environment_allowlist and selected not in environment_allowlist: + raise StudioError( + "MODEL_NOT_AVAILABLE", + "请求模型不在当前运行环境的模型白名单中", + status_code=422, + details={"model": selected}, + ) + return selected + + def _load_build_manifest(self, artifact_path: str) -> CodexAgentManifest: + archive_path = self.workspace.resolve(artifact_path, must_exist=True) + try: + with zipfile.ZipFile(archive_path) as archive: + payload = yaml.safe_load(archive.read("agentengine.yaml")) + return cast(CodexAgentManifest, CodexAgentManifest.model_validate(payload)) + except (OSError, KeyError, ValueError, zipfile.BadZipFile) as exc: + raise StudioError( + "CODEX_BUILD_INVALID", + "Codex Build 缺少有效的 agentengine.yaml", + status_code=500, + ) from exc + + +__all__ = ["CodexRunSpecResolver"] diff --git a/ksadk/studio/compiler.py b/ksadk/studio/compiler.py new file mode 100644 index 00000000..9ecf14c6 --- /dev/null +++ b/ksadk/studio/compiler.py @@ -0,0 +1,189 @@ +"""Deterministic compilation from AgentDraft to ResolvedAgentSpec.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +from ksadk.studio.capabilities import ( + CapabilityResolver, + LocalCapabilityResolver, + canonical_json, + sha256_digest, +) +from ksadk.studio.contracts import ( + AgentDraft, + CapabilitiesSpec, + ResolvedAgentSpec, + ResolvedCapabilities, +) +from ksadk.studio.errors import StudioError +from ksadk.studio.resource_catalog import LocalResourceCatalog +from ksadk.studio.validator import AgentValidator +from ksadk.studio.workspace import Workspace + + +@dataclass(frozen=True) +class CompileResult: + resolved: ResolvedAgentSpec + dependency_lock: dict[str, Any] + + +class AgentCompiler: + def __init__( + self, + workspace: Workspace, + *, + resolver: CapabilityResolver | None = None, + validator: AgentValidator | None = None, + catalog: LocalResourceCatalog | None = None, + ) -> None: + self.workspace = workspace + self.resolver = resolver or LocalCapabilityResolver(workspace) + self.validator = validator or AgentValidator() + self.catalog = catalog or LocalResourceCatalog(workspace) + + def compile(self, draft: AgentDraft) -> CompileResult: + source_payload = draft.model_dump(by_alias=True, exclude_none=True, mode="json") + source_digest = sha256_digest(canonical_json(source_payload)) + materialized = self._materialize_bindings(draft) + self.validator.validate(materialized, level="build", raise_on_error=True) + assert materialized.spec.model is not None + + skills = [ + self.resolver.resolve_skill(ref) + for ref in materialized.spec.capabilities.skills + if ref.enabled + ] + mcp_servers = [ + self.resolver.resolve_mcp(ref) + for ref in materialized.spec.capabilities.mcp_servers + if ref.enabled + ] + tools = [ + self.resolver.resolve_tool(tool) + for tool in materialized.spec.capabilities.tools + ] + skills.sort(key=lambda item: (item["name"], item["version"], item["digest"])) + mcp_servers.sort(key=lambda item: (item["name"], item["version"], item["digest"])) + tools.sort(key=lambda item: (item.name, item.version, item.digest or "")) + + resolved = ResolvedAgentSpec( + agent_id=draft.metadata.id, + source_revision=draft.metadata.revision, + instructions=materialized.spec.instructions, + model=self.resolver.resolve_model(materialized.spec.model), + capabilities=ResolvedCapabilities( + skills=skills, + mcp_servers=mcp_servers, + tools=tools, + ), + execution=materialized.spec.execution, + context=materialized.spec.context, + security=materialized.spec.security, + evaluation=materialized.spec.evaluation, + source_digest=source_digest, + ) + digest_payload = resolved.model_dump( + by_alias=True, + exclude={"resolved_digest"}, + exclude_none=True, + mode="json", + ) + resolved.resolved_digest = sha256_digest(canonical_json(digest_payload)) + dependency_lock = { + "lockVersion": "agentkit.lock/v1", + "agentId": draft.metadata.id, + "sourceRevision": draft.metadata.revision, + "resolvedDigest": resolved.resolved_digest, + "model": resolved.model.model_dump( + by_alias=True, + exclude_none=True, + mode="json", + ), + "skills": skills, + "mcpServers": mcp_servers, + "tools": [ + tool.model_dump(by_alias=True, exclude_none=True, mode="json") + for tool in tools + ], + } + return CompileResult(resolved=resolved, dependency_lock=dependency_lock) + + def _materialize_bindings(self, draft: AgentDraft) -> AgentDraft: + bindings = draft.spec.bindings + if ( + not bindings.model_profile_id + and not bindings.tools + and not bindings.mcp_servers + and not bindings.skills + ): + return draft + materialized = draft.model_copy(deep=True) + model = self.catalog.resolve_model(bindings) or materialized.spec.model + if model is None: + raise StudioError( + "AGENT_MODEL_REQUIRED", + "必须选择一个可用的 Model Profile", + status_code=422, + field="spec.bindings.modelProfileId", + ) + bound_tools, permissions = self.catalog.policy_preview(bindings) + bound_mcp_tools = self.catalog.resolve_mcp_tools(bindings) + capabilities = CapabilitiesSpec( + skills=[ + *materialized.spec.capabilities.skills, + *self.catalog.resolve_skills(bindings), + ], + mcp_servers=[ + *materialized.spec.capabilities.mcp_servers, + *self.catalog.resolve_mcp_servers(bindings), + ], + tools=[ + *materialized.spec.capabilities.tools, + *bound_tools, + *bound_mcp_tools, + ], + ) + self._require_unique( + [item.name for item in capabilities.skills], + kind="Skill", + ) + self._require_unique( + [item.name for item in capabilities.mcp_servers], + kind="MCP Server", + ) + self._require_unique( + [item.name for item in capabilities.tools], + kind="Tool", + ) + materialized.spec.model = model + materialized.spec.capabilities = capabilities + materialized.spec.security.allowed_permissions = sorted( + set(materialized.spec.security.allowed_permissions) + | set(permissions) + | { + permission + for tool in bound_mcp_tools + for permission in tool.permissions + } + ) + endpoint = model.endpoint_url or model.base_url or "" + hostname = (urlparse(endpoint).hostname or "").lower().rstrip(".") + if hostname and materialized.spec.security.network.mode == "restricted": + materialized.spec.security.network.allowed_hosts = sorted( + set(materialized.spec.security.network.allowed_hosts) | {hostname} + ) + return materialized + + @staticmethod + def _require_unique(names: list[str], *, kind: str) -> None: + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise StudioError( + "CAPABILITY_DUPLICATE", + f"{kind} binding 重复", + status_code=422, + details={"names": duplicates}, + ) diff --git a/ksadk/studio/contracts.py b/ksadk/studio/contracts.py new file mode 100644 index 00000000..f8057d08 --- /dev/null +++ b/ksadk/studio/contracts.py @@ -0,0 +1,644 @@ +"""Pydantic contracts for local authoring, builds, runs, and deployment.""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +def _to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.capitalize() for part in tail) + + +class ContractModel(BaseModel): + model_config = ConfigDict( + alias_generator=_to_camel, + populate_by_name=True, + extra="forbid", + use_enum_values=True, + ) + + +class DiagnosticSeverity(str, Enum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class Diagnostic(ContractModel): + severity: DiagnosticSeverity + code: str + message: str + field: str | None = None + hint: str | None = None + + +class Instructions(ContractModel): + system: str = Field(default="", max_length=32768) + task: str = Field(default="", max_length=32768) + + +class ModelParameters(ContractModel): + temperature: float = Field(default=0.2, ge=0, le=2) + max_tokens: int = Field(default=2048, ge=1, le=131072) + top_p: float | None = Field(default=None, gt=0, le=1) + + +class ModelSpec(ContractModel): + provider: str = Field(default="openai-compatible", min_length=1, max_length=64) + model: str = Field(min_length=1, max_length=256) + endpoint_url: str | None = None + base_url: str | None = None + wire_api: Literal["chat", "responses"] | None = None + credential_ref: str = Field(min_length=1, max_length=512) + parameters: ModelParameters = Field(default_factory=ModelParameters) + metadata: dict[str, Any] = Field(default_factory=dict) + discovery: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_address(self) -> "ModelSpec": + if bool(self.endpoint_url) == bool(self.base_url): + raise ValueError("endpointUrl 和 baseUrl 必须且只能配置一个") + return self + + +class CapabilityRef(ContractModel): + name: str = Field(min_length=1, max_length=128) + version: str = Field(min_length=1, max_length=64) + digest: str | None = None + enabled: bool = True + + +class MCPServerRef(CapabilityRef): + transport: Literal["stdio", "http", "sse"] = "stdio" + command: str | None = None + args: list[str] = Field(default_factory=list) + endpoint_url: str | None = None + env_refs: dict[str, str] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_transport(self) -> "MCPServerRef": + if self.transport == "stdio" and not self.command: + raise ValueError("stdio MCP 必须配置 command") + if self.transport in {"http", "sse"} and not self.endpoint_url: + raise ValueError("HTTP/SSE MCP 必须配置 endpointUrl") + return self + + +class ToolContract(ContractModel): + name: str = Field(min_length=1, max_length=128) + version: str = Field(min_length=1, max_length=64) + description: str = Field(default="", max_length=1024) + input_schema: dict[str, Any] = Field( + default_factory=lambda: {"type": "object", "properties": {}} + ) + output_schema: dict[str, Any] = Field( + default_factory=lambda: {"type": "object", "properties": {}} + ) + permissions: list[str] = Field(default_factory=list) + timeout_seconds: int = Field(default=20, ge=1, le=3600) + side_effect: Literal["none", "read", "write", "external"] = "none" + approval: Literal["never", "always", "policy"] = "never" + executor: Literal["builtin", "mcp", "deferred", "python"] = "builtin" + mcp_server: str | None = None + source_path: str | None = Field(default=None, min_length=1, max_length=1024) + callable_name: str | None = Field(default=None, min_length=1, max_length=256) + source_sha256: str | None = None + digest: str | None = None + group: str | None = None + risk_level: str | None = None + boundary: str | None = None + backend: str | None = None + enabled: bool = True + + @model_validator(mode="after") + def validate_executor(self) -> "ToolContract": + if self.executor == "mcp" and not self.mcp_server: + raise ValueError("MCP Tool 必须配置 mcpServer") + if self.executor != "mcp" and self.mcp_server: + raise ValueError("只有 MCP Tool 可以配置 mcpServer") + if self.executor == "python": + if not self.source_path or not self.callable_name: + raise ValueError("Python Tool 必须配置 sourcePath 和 callableName") + normalized = self.source_path.strip().replace("\\", "/") + if ( + normalized.startswith("/") + or normalized == ".." + or normalized.startswith("../") + or "/../" in normalized + ): + raise ValueError("Python Tool sourcePath 必须位于工作区内") + self.source_path = normalized + elif self.source_path or self.callable_name or self.source_sha256: + raise ValueError("只有 Python Tool 可以配置源码字段") + return self + + +class ResourceDescriptor(ContractModel): + resource_id: str = Field(min_length=3, max_length=256) + kind: Literal["model", "tool", "mcp", "skill"] + name: str = Field(min_length=1, max_length=128) + display_name: str = Field(min_length=1, max_length=128) + version: str = Field(min_length=1, max_length=64) + digest: str + source: Literal["builtin", "provider", "local", "market"] = "local" + status: Literal[ + "ready", + "unhealthy", + "invalid", + "missing-secret", + "unresolved", + ] = "ready" + description: str = Field(default="", max_length=4096) + category: str = Field(default="general", max_length=64) + installed: bool = True + required_secret_refs: list[str] = Field(default_factory=list) + contract: dict[str, Any] = Field(default_factory=dict) + health: dict[str, Any] = Field(default_factory=dict) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class CapabilityBinding(ContractModel): + resource_id: str = Field(min_length=3, max_length=256) + enabled: bool = True + approval: Literal["never", "always", "policy"] | None = None + config: dict[str, Any] = Field(default_factory=dict) + + +class AgentBindings(ContractModel): + model_profile_id: str | None = None + model_profile_ids: list[str] = Field(default_factory=list) + model_parameters: ModelParameters | None = None + policy_template: Literal["loose", "strict", "custom"] = "strict" + tools: list[CapabilityBinding] = Field(default_factory=list) + mcp_servers: list[CapabilityBinding] = Field(default_factory=list) + skills: list[CapabilityBinding] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_model_profiles(self) -> "AgentBindings": + if len(set(self.model_profile_ids)) != len(self.model_profile_ids): + raise ValueError("modelProfileIds 不能包含重复资源") + if self.model_profile_ids and not self.model_profile_id: + raise ValueError("绑定多个模型时必须指定默认 modelProfileId") + if ( + self.model_profile_id + and self.model_profile_ids + and self.model_profile_id not in self.model_profile_ids + ): + raise ValueError("默认 modelProfileId 必须包含在 modelProfileIds 中") + return self + + +class CapabilitiesSpec(ContractModel): + skills: list[CapabilityRef] = Field(default_factory=list) + mcp_servers: list[MCPServerRef] = Field(default_factory=list) + tools: list[ToolContract] = Field(default_factory=list) + + +class RetryPolicy(ContractModel): + max_attempts: int = Field(default=2, ge=1, le=10) + backoff_seconds: float = Field(default=1, ge=0, le=60) + + +class ExecutionSpec(ContractModel): + strategy: Literal["direct", "plan-act-observe"] = "direct" + max_steps: int = Field(default=12, ge=1, le=100) + timeout_seconds: int = Field(default=120, ge=1, le=3600) + retry: RetryPolicy = Field(default_factory=RetryPolicy) + sandbox: str | None = None + approval_mode: str | None = None + + +class CompactionSpec(ContractModel): + enabled: bool = True + threshold_ratio: float = Field(default=0.8, gt=0, le=1) + + +class ContextSpec(ContractModel): + max_input_tokens: int = Field(default=32000, ge=1024) + reserve_output_tokens: int = Field(default=4096, ge=1) + compaction: CompactionSpec = Field(default_factory=CompactionSpec) + + @model_validator(mode="after") + def validate_budget(self) -> "ContextSpec": + if self.reserve_output_tokens >= self.max_input_tokens: + raise ValueError("reserveOutputTokens 必须小于 maxInputTokens") + return self + + +class NetworkPolicy(ContractModel): + mode: Literal["restricted", "open"] = "restricted" + allowed_hosts: list[str] = Field(default_factory=list) + allow_private_network: bool = False + + +class SecuritySpec(ContractModel): + tool_policy: Literal["deny-by-default", "allow-listed"] = "deny-by-default" + allowed_permissions: list[str] = Field(default_factory=list) + network: NetworkPolicy = Field(default_factory=NetworkPolicy) + + +class EvaluationSpec(ContractModel): + suite_refs: list[str] = Field(default_factory=list) + minimum_pass_rate: float = Field(default=1, ge=0, le=1) + + +class RuntimeRef(ContractModel): + """Per-Agent reference to one registered RuntimeAdapter implementation. + + Studio is a control plane and therefore must not have a process-wide runtime + mode. Each Agent declares the adapter type and, for Python frameworks, the + project entrypoint that is snapshotted by its Build. + """ + + type: Literal["codex", "adk", "langgraph"] + project_path: str | None = Field(default=None, min_length=1, max_length=1024) + entry_point: str | None = Field(default=None, min_length=1, max_length=1024) + agent_variable: str = Field(default="root_agent", min_length=1, max_length=256) + version: str | None = Field(default=None, min_length=1, max_length=64) + detection: Literal["declared", "auto"] = "declared" + + @field_validator("project_path", "entry_point") + @classmethod + def validate_relative_path(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().replace("\\", "/") + if ( + not normalized + or normalized.startswith("/") + or normalized == ".." + or normalized.startswith("../") + or "/../" in normalized + ): + raise ValueError("Runtime 路径必须是工作区内的相对路径") + return normalized + + @model_validator(mode="after") + def validate_framework_source(self) -> "RuntimeRef": + if self.type in {"adk", "langgraph"}: + if not self.project_path: + raise ValueError(f"{self.type} Runtime 必须配置 projectPath") + if self.detection == "declared" and not self.entry_point: + raise ValueError(f"{self.type} Runtime 使用 declared 检测时必须配置 entryPoint") + return self + + +class AgentSpec(ContractModel): + description: str = Field(default="", max_length=1024) + runtime: RuntimeRef | None = None + instructions: Instructions = Field(default_factory=Instructions) + model: ModelSpec | None = None + capabilities: CapabilitiesSpec = Field(default_factory=CapabilitiesSpec) + bindings: AgentBindings = Field(default_factory=AgentBindings) + execution: ExecutionSpec = Field(default_factory=ExecutionSpec) + context: ContextSpec = Field(default_factory=ContextSpec) + security: SecuritySpec = Field(default_factory=SecuritySpec) + evaluation: EvaluationSpec = Field(default_factory=EvaluationSpec) + + +_AGENT_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]{2,62}$") +_AGENT_AVATAR_URL_PATTERN = re.compile(r"^/api/v1/assets/agent-avatars/[0-9a-f]{64}\.(?:png|webp)$") + + +class AgentAppearance(ContractModel): + icon: Literal["bot", "sparkles", "search", "code", "workflow"] = "bot" + color: str = Field(default="#426ea8", pattern=r"^#[0-9a-fA-F]{6}$") + image_url: str | None = Field(default=None, max_length=160) + + @field_validator("color") + @classmethod + def normalize_color(cls, value: str) -> str: + return value.lower() + + @field_validator("image_url") + @classmethod + def validate_image_url(cls, value: str | None) -> str | None: + if value is not None and not _AGENT_AVATAR_URL_PATTERN.fullmatch(value): + raise ValueError("imageUrl 必须引用当前工作区的内容寻址头像资源") + return value + + +class AgentMetadata(ContractModel): + id: str + name: str = Field(min_length=1, max_length=128) + revision: int = Field(default=1, ge=1) + labels: dict[str, str] = Field(default_factory=dict) + appearance: AgentAppearance = Field(default_factory=AgentAppearance) + + @field_validator("id") + @classmethod + def validate_agent_id(cls, value: str) -> str: + if not _AGENT_ID_PATTERN.fullmatch(value): + raise ValueError("id 必须以小写字母开头,仅包含小写字母、数字和连字符,长度 3..63") + return value + + +class AgentDraft(ContractModel): + api_version: Literal["agentkit.ksyun.com/v1alpha1"] = "agentkit.ksyun.com/v1alpha1" + kind: Literal["Agent"] = "Agent" + metadata: AgentMetadata + spec: AgentSpec = Field(default_factory=AgentSpec) + + +class AgentTemplateComposeRequest(ContractModel): + prompt: str = Field(default="", max_length=32768) + goal: str = Field(default="", max_length=4096) + description: str = Field(default="", max_length=1024) + task_prompt: str = Field(default="", max_length=32768) + audience: str = Field(default="技术与业务决策者", min_length=1, max_length=256) + language: Literal["zh-CN", "en-US"] = "zh-CN" + depth: Literal["focused", "standard", "deep"] = "deep" + output_format: Literal["brief", "report", "evidence-table"] = "report" + model_profile_id: str | None = None + model_profile_ids: list[str] = Field(default_factory=list) + tool_resource_ids: list[str] = Field(default_factory=list) + skill_resource_ids: list[str] = Field(default_factory=list) + mcp_resource_ids: list[str] = Field(default_factory=list) + policy_template: Literal["loose", "strict", "custom"] = "strict" + execution_strategy: Literal["direct", "plan-act-observe"] = "direct" + max_steps: int = Field(default=12, ge=1, le=100) + timeout_seconds: int = Field(default=120, ge=1, le=3600) + auto_bind_tools: bool = True + auto_bind_mcp: bool = True + + @model_validator(mode="after") + def validate_authoring_prompt(self) -> "AgentTemplateComposeRequest": + if not self.prompt.strip() and not self.goal.strip(): + raise ValueError("prompt 和 goal 至少填写一项") + return self + + +class AgentTemplateRecommendation(ContractModel): + kind: Literal["model", "tool", "mcp", "skill"] + status: Literal["bound", "available", "missing", "warning"] + title: str + reason: str + resource_id: str | None = None + + +class AgentTemplateComposition(ContractModel): + template_id: Literal["blank", "research"] + spec: AgentSpec + recommendations: list[AgentTemplateRecommendation] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + +class ResolvedModel(ContractModel): + provider: str + model: str + endpoint_url: str + credential_ref: str + parameters: ModelParameters + wire_api: str | None = None + + +class ResolvedCapabilities(ContractModel): + skills: list[dict[str, Any]] = Field(default_factory=list) + mcp_servers: list[dict[str, Any]] = Field(default_factory=list) + tools: list[ToolContract] = Field(default_factory=list) + + +class ResolvedAgentSpec(ContractModel): + schema_version: Literal["agentkit.resolved/v1"] = "agentkit.resolved/v1" + agent_id: str + source_revision: int + compiler_version: str = "1" + instructions: Instructions + model: ResolvedModel + capabilities: ResolvedCapabilities + execution: ExecutionSpec + context: ContextSpec + security: SecuritySpec + evaluation: EvaluationSpec + source_digest: str + resolved_digest: str = "" + + +class ValidationResult(ContractModel): + valid: bool + level: Literal["schema", "build", "release"] = "build" + diagnostics: list[Diagnostic] = Field(default_factory=list) + + +class BuildStatus(str, Enum): + QUEUED = "QUEUED" + VALIDATING = "VALIDATING" + RESOLVING = "RESOLVING" + COMPILING = "COMPILING" + PACKAGING = "PACKAGING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + INTERRUPTED = "INTERRUPTED" + + +class FileEntry(ContractModel): + path: str + sha256: str + size: int = Field(ge=0) + + +class BundleManifest(ContractModel): + bundle_format: Literal["agentkit.bundle/v1"] = "agentkit.bundle/v1" + agent_id: str + source_revision: int + resolved_digest: str + runtime_type: str = "" + source_digest: str = "" + runtime_contract: Literal["agentkit.runtime/v1"] = "agentkit.runtime/v1" + files: list[FileEntry] + created_at: str = "1970-01-01T00:00:00Z" + bundle_digest: str = "" + + +class BuildRecord(ContractModel): + id: str + agent_id: str + source_revision: int + status: BuildStatus + resolved_digest: str = "" + runtime_type: str = "" + source_digest: str = "" + runtime_lock: dict[str, Any] = Field(default_factory=dict) + bundle_digest: str = "" + artifact_path: str | None = None + diagnostics: list[Diagnostic] = Field(default_factory=list) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + completed_at: datetime | None = None + + +class OperationKind(str, Enum): + BUILD = "BUILD" + RUN = "RUN" + EVALUATION = "EVALUATION" + DEPLOYMENT = "DEPLOYMENT" + + +class OperationStatus(str, Enum): + QUEUED = "QUEUED" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + INTERRUPTED = "INTERRUPTED" + + +class Operation(ContractModel): + id: str + kind: OperationKind + status: OperationStatus = OperationStatus.QUEUED + resource_id: str + error: dict[str, Any] | None = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + completed_at: datetime | None = None + + +class OperationEvent(ContractModel): + id: int = Field(ge=1) + operation_id: str + type: str + data: dict[str, Any] = Field(default_factory=dict) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class RunStatus(str, Enum): + CREATED = "CREATED" + RUNNING = "RUNNING" + PAUSED = "PAUSED" + WAITING_INPUT = "WAITING_INPUT" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + INTERRUPTED = "INTERRUPTED" + TIMED_OUT = "TIMED_OUT" + + +class Usage(ContractModel): + input_tokens: int = Field(default=0, ge=0) + output_tokens: int = Field(default=0, ge=0) + total_tokens: int = Field(default=0, ge=0) + cached_input_tokens: int = Field(default=0, ge=0) + reasoning_output_tokens: int = Field(default=0, ge=0) + reported: bool = False + source: str | None = None + + +class RunRecord(ContractModel): + id: str + build_id: str + agent_id: str + session_id: str + trace_id: str + manifest_sha256: str = "" + runtime_type: str = "" + model: str = "" + collaboration_mode: str = "" + goal_objective: str = "" + runtime_handle: dict[str, Any] = Field(default_factory=dict) + status: RunStatus = RunStatus.CREATED + input: str + output: str = "" + usage: Usage = Field(default_factory=Usage) + error: dict[str, Any] | None = None + started_at: datetime | None = None + completed_at: datetime | None = None + duration_ms: int | None = None + duration_source: Literal["runtime", "studio"] | None = None + + +class RunEvent(ContractModel): + id: int = Field(ge=1) + run_id: str + type: str + data: dict[str, Any] = Field(default_factory=dict) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class AssertionSpec(ContractModel): + type: Literal[ + "contains", + "equals", + "notContains", + "jsonSchema", + "maxLatencyMs", + "maxInputTokens", + "maxOutputTokens", + "toolCalled", + "toolNotCalled", + ] + value: Any + + +class EvaluationCase(ContractModel): + id: str = Field(min_length=1, max_length=128) + input: str + assertions: list[AssertionSpec] = Field(min_length=1) + + +class EvaluationSuite(ContractModel): + api_version: Literal["agentkit.ksyun.com/v1alpha1"] = "agentkit.ksyun.com/v1alpha1" + kind: Literal["EvaluationSuite"] = "EvaluationSuite" + metadata: dict[str, Any] + cases: list[EvaluationCase] = Field(min_length=1) + + +class AssertionResult(ContractModel): + assertion: AssertionSpec + passed: bool + message: str = "" + + +class EvaluationCaseResult(ContractModel): + case_id: str + run_id: str + passed: bool + assertions: list[AssertionResult] + + +class EvaluationRun(ContractModel): + id: str + build_id: str + status: RunStatus = RunStatus.CREATED + total: int = 0 + passed: int = 0 + failed: int = 0 + pass_rate: float = 0 + results: list[EvaluationCaseResult] = Field(default_factory=list) + + +class DeploymentTarget(ContractModel): + region: str = Field(min_length=1, max_length=64) + environment: str = Field(min_length=1, max_length=64) + + +class EnvironmentBinding(ContractModel): + model: dict[str, str] = Field(default_factory=dict) + secrets: dict[str, str] = Field(default_factory=dict) + mcp_servers: dict[str, str] = Field(default_factory=dict) + + +class ReleasePolicy(ContractModel): + strategy: Literal["rolling", "recreate"] = "rolling" + approval: Literal["none", "manual"] = "none" + + +class DeploymentRequest(ContractModel): + target: DeploymentTarget + binding: EnvironmentBinding = Field(default_factory=EnvironmentBinding) + release_policy: ReleasePolicy = Field(default_factory=ReleasePolicy) + + +class DeploymentRecord(ContractModel): + id: str + build_id: str + bundle_digest: str + version_id: str + status: Literal["ADMITTING", "DEPLOYING", "READY", "FAILED", "ROLLED_BACK"] + target: DeploymentTarget diff --git a/ksadk/studio/errors.py b/ksadk/studio/errors.py new file mode 100644 index 00000000..13a3539f --- /dev/null +++ b/ksadk/studio/errors.py @@ -0,0 +1,47 @@ +"""Stable errors shared by the Studio service and HTTP API.""" + +from __future__ import annotations + +from typing import Any + + +class StudioError(Exception): + """A user-safe error with a stable machine-readable code.""" + + def __init__( + self, + code: str, + message: str, + *, + status_code: int = 400, + field: str | None = None, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + self.field = field + self.details = details or {} + + def as_dict(self, *, request_id: str | None = None) -> dict[str, Any]: + payload: dict[str, Any] = { + "code": self.code, + "message": self.message, + } + if self.field: + payload["field"] = self.field + if self.details: + payload["details"] = self.details + if request_id: + payload["requestId"] = request_id + return {"error": payload} + + +def not_found(resource: str, resource_id: str) -> StudioError: + return StudioError( + f"{resource.upper()}_NOT_FOUND", + f"{resource} 不存在", + status_code=404, + details={"id": resource_id}, + ) diff --git a/ksadk/studio/evaluation.py b/ksadk/studio/evaluation.py new file mode 100644 index 00000000..c4f6696e --- /dev/null +++ b/ksadk/studio/evaluation.py @@ -0,0 +1,192 @@ +"""Local evaluation suites and deterministic assertions.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import cast +from uuid import uuid4 + +from jsonschema import ( # type: ignore[import-untyped] + ValidationError as JSONSchemaValidationError, +) +from jsonschema import validate as validate_json # type: ignore[import-untyped] + +from ksadk.studio.contracts import ( + AssertionResult, + AssertionSpec, + EvaluationCaseResult, + EvaluationRun, + EvaluationSuite, + RunRecord, + RunStatus, +) +from ksadk.studio.errors import StudioError +from ksadk.studio.event_store import RunEventStore +from ksadk.studio.repository import BuildRepository, load_yaml_file +from ksadk.studio.workspace import Workspace + + +class EvaluationRunner: + def __init__( + self, + workspace: Workspace, + *, + run_agent: Callable[[str, str, str | None], Awaitable[RunRecord]], + event_store: RunEventStore, + build_repository: BuildRepository | None = None, + ) -> None: + self.workspace = workspace + self.run_agent = run_agent + self.event_store = event_store + self.build_repository = build_repository or BuildRepository(workspace) + + async def run( + self, + build_id: str, + suite_refs: list[str], + *, + fail_fast: bool = False, + ) -> EvaluationRun: + build = self.build_repository.get(build_id) + suites = [ + self._load_suite(build.agent_id, reference) for reference in suite_refs + ] + evaluation = EvaluationRun( + id=f"eval_{uuid4().hex}", + build_id=build_id, + status=RunStatus.RUNNING, + ) + for suite in suites: + for case in suite.cases: + run = await self.run_agent( + build_id, + case.input, + f"eval_{evaluation.id}_{case.id}", + ) + assertion_results = [ + self._assert(assertion, run) for assertion in case.assertions + ] + passed = run.status == RunStatus.COMPLETED and all( + result.passed for result in assertion_results + ) + evaluation.results.append( + EvaluationCaseResult( + case_id=case.id, + run_id=run.id, + passed=passed, + assertions=assertion_results, + ) + ) + if fail_fast and not passed: + break + if fail_fast and evaluation.results and not evaluation.results[-1].passed: + break + evaluation.total = len(evaluation.results) + evaluation.passed = sum(1 for result in evaluation.results if result.passed) + evaluation.failed = evaluation.total - evaluation.passed + evaluation.pass_rate = ( + evaluation.passed / evaluation.total if evaluation.total else 0 + ) + evaluation.status = ( + RunStatus.COMPLETED if evaluation.failed == 0 else RunStatus.FAILED + ) + self._save(evaluation) + return evaluation + + def get(self, evaluation_id: str) -> EvaluationRun: + path = self.workspace.resolve( + Path(".agentkit/evaluations") / f"{evaluation_id}.json" + ) + if not path.is_file(): + raise StudioError( + "EVALUATION_NOT_FOUND", + "Evaluation 不存在", + status_code=404, + details={"id": evaluation_id}, + ) + return cast( + EvaluationRun, + EvaluationRun.model_validate_json(path.read_text(encoding="utf-8")), + ) + + def _load_suite(self, agent_id: str, reference: str) -> EvaluationSuite: + candidates = [ + Path("agents") / agent_id / reference, + Path(reference), + ] + for candidate in candidates: + path = self.workspace.resolve(candidate) + if path.is_file(): + try: + return cast( + EvaluationSuite, + EvaluationSuite.model_validate(load_yaml_file(path)), + ) + except ValueError as exc: + raise StudioError( + "EVALUATION_SUITE_INVALID", + "评测集格式无效", + status_code=422, + details={"reference": reference, "reason": str(exc)}, + ) from exc + raise StudioError( + "EVALUATION_SUITE_NOT_FOUND", + "评测集不存在", + status_code=404, + details={"reference": reference}, + ) + + def _assert(self, assertion: AssertionSpec, run: RunRecord) -> AssertionResult: + value = assertion.value + passed = False + message = "" + if assertion.type == "contains": + passed = str(value) in run.output + elif assertion.type == "equals": + passed = run.output == str(value) + elif assertion.type == "notContains": + passed = str(value) not in run.output + elif assertion.type == "maxLatencyMs": + passed = (run.duration_ms or 0) <= int(value) + elif assertion.type == "maxInputTokens": + passed = run.usage.input_tokens <= int(value) + elif assertion.type == "maxOutputTokens": + passed = run.usage.output_tokens <= int(value) + elif assertion.type == "jsonSchema": + try: + validate_json(json.loads(run.output), value) + passed = True + except (ValueError, JSONSchemaValidationError) as exc: + message = str(exc) + elif assertion.type in {"toolCalled", "toolNotCalled"}: + called = { + event.data.get("tool") + for event in self.event_store.events(run.id) + if event.type == "tool.requested" + } + passed = ( + str(value) in called + if assertion.type == "toolCalled" + else str(value) not in called + ) + if not message and not passed: + message = f"断言 {assertion.type} 未通过" + return AssertionResult(assertion=assertion, passed=passed, message=message) + + def _save(self, evaluation: EvaluationRun) -> None: + directory = self.workspace.resolve(".agentkit/evaluations") + directory.mkdir(parents=True, exist_ok=True) + self.workspace.atomic_write_text( + directory / f"{evaluation.id}.json", + json.dumps( + evaluation.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), + ensure_ascii=False, + sort_keys=True, + indent=2, + ) + + "\n", + ) diff --git a/ksadk/studio/event_store.py b/ksadk/studio/event_store.py new file mode 100644 index 00000000..28ec0abd --- /dev/null +++ b/ksadk/studio/event_store.py @@ -0,0 +1,284 @@ +"""Persistent local Run and Event store.""" + +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import List + +from pydantic import ValidationError + +from ksadk.studio.contracts import RunEvent, RunRecord, RunStatus +from ksadk.studio.errors import StudioError, not_found +from ksadk.studio.otel_trace import OtlpTraceStore +from ksadk.studio.workspace import Workspace + + +class RunEventStore: + def __init__(self, workspace: Workspace) -> None: + self.workspace = workspace + self.trace_store = OtlpTraceStore(workspace) + + def _path(self, run_id: str) -> Path: + return self.workspace.resolve(Path(".agentkit/runs") / f"{run_id}.json") + + def create(self, record: RunRecord) -> RunRecord: + path = self._path(record.id) + if path.exists(): + raise StudioError("RUN_ALREADY_EXISTS", "Run 已存在", status_code=409) + self._write(record, []) + return record + + def save(self, record: RunRecord) -> RunRecord: + _, events = self._read(record.id) + self._write(record, events) + return record + + def append(self, run_id: str, event_type: str, data: dict) -> RunEvent: + record, events = self._read(run_id) + event = RunEvent( + id=len(events) + 1, + run_id=run_id, + type=event_type, + data=data, + ) + events.append(event) + self._write(record, events) + return event + + def get(self, run_id: str) -> RunRecord: + record, _ = self._read(run_id) + return record + + def events(self, run_id: str, *, after: int = 0) -> list[RunEvent]: + _, events = self._read(run_id) + return [event for event in events if event.id > after] + + def list_runs( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + ) -> List[RunRecord]: + records: list[RunRecord] = [] + directory = self.workspace.resolve(".agentkit/runs") + for path in sorted(directory.glob("run_*.json")): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + record = RunRecord.model_validate(payload["record"]) + except (OSError, ValueError, KeyError, ValidationError): + continue + if session_id and record.session_id != session_id: + continue + if agent_id and record.agent_id != agent_id: + continue + records.append(record) + records.sort( + key=lambda item: ( + item.started_at or item.completed_at or datetime.min.replace(tzinfo=timezone.utc) + ), + reverse=False, + ) + return records + + def delete_session(self, session_id: str) -> int: + """Delete every persisted run that belongs to one local chat session.""" + + deleted = 0 + for record in self.list_runs(session_id=session_id): + path = self._path(record.id) + if path.is_file(): + self.trace_store.delete(record.trace_id, purge=True) + path.unlink() + deleted += 1 + return deleted + + def delete_agent( + self, + agent_id: str, + *, + purge: bool, + trash_directory: Path | None = None, + ) -> int: + """Remove the persisted Runs and Trace events owned by one Agent.""" + + deleted = 0 + for record in self.list_runs(agent_id=agent_id): + path = self._path(record.id) + if not path.is_file(): + continue + if purge: + self.trace_store.delete(record.trace_id, purge=True) + path.unlink() + else: + if trash_directory is None: + raise ValueError("recoverable deletion requires a trash directory") + destination = self.workspace.resolve(trash_directory / "runs" / path.name) + destination.parent.mkdir(parents=True, exist_ok=True) + self.trace_store.delete( + record.trace_id, + purge=False, + trash_directory=trash_directory, + ) + shutil.move(str(path), str(destination)) + deleted += 1 + return deleted + + def recover_interrupted(self) -> int: + """Reconcile non-terminal records left behind by a stopped Studio. + + Events are persisted before the final RunRecord update. A browser + disconnect or process stop can therefore leave a terminal event next + to a stale ``RUNNING`` record. On startup the event log wins; a run + without any terminal event is explicitly marked interrupted because + its in-memory runtime task cannot survive a Studio restart. + """ + + recovered = 0 + for record in self.list_runs(): + if record.status not in { + RunStatus.CREATED, + RunStatus.RUNNING, + RunStatus.PAUSED, + RunStatus.WAITING_INPUT, + }: + continue + events = self.events(record.id) + terminal = next( + ( + event + for event in reversed(events) + if event.type + in { + "run.completed", + "run.failed", + "run.cancelled", + "run.interrupted", + } + ), + None, + ) + if terminal is None: + terminal = self.append( + record.id, + "run.interrupted", + { + "status": "interrupted", + "reason": "studio_restarted", + }, + ) + + if terminal.type == "run.completed": + record.status = RunStatus.COMPLETED + record.error = None + elif terminal.type == "run.failed": + record.status = RunStatus.FAILED + record.error = { + "code": "RUNTIME_RUN_FAILED", + "message": str( + terminal.data.get("error") + or terminal.data.get("message") + or "Agent 运行失败" + ), + } + elif terminal.type == "run.cancelled": + record.status = RunStatus.CANCELLED + record.error = {"code": "RUN_CANCELLED", "message": "运行已取消"} + else: + record.status = RunStatus.INTERRUPTED + record.error = { + "code": "RUN_INTERRUPTED", + "message": "Studio 重启后无法重新 attach 上一次本地运行", + } + record.completed_at = terminal.created_at + if record.started_at is not None: + record.duration_ms = max( + 0, + int((record.completed_at - record.started_at).total_seconds() * 1000), + ) + self.save(record) + recovered += 1 + return recovered + + def trace(self, trace_id: str) -> dict: + return self.trace_store.get_trace_view(trace_id) + + def trace_otlp(self, trace_id: str) -> dict: + return self.trace_store.get_otlp(trace_id) + + def list_traces( + self, + *, + agent_id: str | None = None, + status: str | None = None, + limit: int = 200, + ) -> list[dict]: + return self.trace_store.list_trace_summaries( + agent_id=agent_id, + status=status, + limit=limit, + ) + + def list_traces_page( + self, + *, + agent_id: str | None = None, + status: str | None = None, + query: str = "", + limit: int = 50, + cursor: str | None = None, + sort: str = "startedAt:desc", + ) -> dict: + return self.trace_store.paginate_trace_summaries( + agent_id=agent_id, + status=status, + query=query, + limit=limit, + cursor=cursor, + sort=sort, + ) + + def trace_overview( + self, + *, + range_name: str = "24h", + agent_id: str | None = None, + status: str | None = None, + ) -> dict: + return self.trace_store.trace_overview( + range_name=range_name, + agent_id=agent_id, + status=status, + ) + + def _read(self, run_id: str) -> tuple[RunRecord, List[RunEvent]]: + path = self._path(run_id) + if not path.is_file(): + raise not_found("run", run_id) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + record = RunRecord.model_validate(payload["record"]) + events = [RunEvent.model_validate(item) for item in payload["events"]] + return record, events + except (OSError, ValueError, KeyError, ValidationError) as exc: + raise StudioError( + "RUN_RECORD_INVALID", + "Run 记录损坏", + status_code=500, + details={"id": run_id}, + ) from exc + + def _write(self, record: RunRecord, events: List[RunEvent]) -> None: + payload = { + "record": record.model_dump(by_alias=True, exclude_none=True, mode="json"), + "events": [ + event.model_dump(by_alias=True, exclude_none=True, mode="json") for event in events + ], + } + self.workspace.atomic_write_text( + self._path(record.id), + json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + ) + self.trace_store.sync(record, events) diff --git a/ksadk/studio/framework_run.py b/ksadk/studio/framework_run.py new file mode 100644 index 00000000..fdadc77c --- /dev/null +++ b/ksadk/studio/framework_run.py @@ -0,0 +1,114 @@ +"""Resolve immutable ADK/LangGraph Studio builds into RuntimeLaunchContext.""" + +from __future__ import annotations + +import json + +from ksadk.detection.detector import FrameworkDetector +from ksadk.runtime import RuntimeLaunchContext +from ksadk.studio.errors import StudioError +from ksadk.studio.repository import BuildRepository +from ksadk.studio.run_service import StudioRunSpec +from ksadk.studio.workspace import Workspace +from ksadk.tools.gateway import normalize_tool_approval_mode + + +class FrameworkRunSpecResolver: + def __init__( + self, + workspace: Workspace, + *, + build_repository: BuildRepository | None = None, + ) -> None: + self.workspace = workspace + self.builds = build_repository or BuildRepository(workspace) + + def resolve( + self, + build_id: str, + *, + model: str | None = None, + approval_mode: str | None = None, + ) -> StudioRunSpec: + build = self.builds.get(build_id) + runtime_type = build.runtime_type.strip().lower() + if runtime_type not in {"adk", "langgraph"}: + raise StudioError( + "BUILD_RUNTIME_UNSUPPORTED", + "Build 没有可由 RuntimeAdapter 启动的 ADK/LangGraph Runtime", + status_code=422, + details={"buildId": build_id, "runtimeType": runtime_type}, + ) + if not build.artifact_path: + raise StudioError("BUILD_NOT_READY", "Build 尚未生成制品", status_code=409) + artifact_root = self.workspace.resolve(build.artifact_path, must_exist=True).parent + bundle_root = artifact_root / "agent-bundle" + project_dir = bundle_root / "runtime" + if not project_dir.is_dir(): + raise StudioError( + "BUILD_RUNTIME_SOURCE_MISSING", + "Build 缺少不可变 Runtime 源码快照", + status_code=500, + ) + detection = FrameworkDetector(str(project_dir)).detect() + if not detection.is_valid or detection.type.value != runtime_type: + raise StudioError( + "BUILD_RUNTIME_DETECTION_MISMATCH", + "Build 中的 Runtime 源码与 Runtime Lock 不一致", + status_code=422, + details={ + "expected": runtime_type, + "detected": detection.type.value, + }, + ) + selected_model = self._select_model(build.runtime_lock, model) + resolved_path = bundle_root / "resolved-agent-spec.json" + resolved = json.loads(resolved_path.read_text(encoding="utf-8")) + instructions = resolved.get("instructions") if isinstance(resolved, dict) else {} + request_config = { + "base_instructions": str((instructions or {}).get("system") or ""), + "entry_point": detection.entry_point, + "agent_variable": detection.agent_variable, + } + if approval_mode: + request_config["tool_approval_mode"] = normalize_tool_approval_mode( + approval_mode + ) + return StudioRunSpec( + launch_context=RuntimeLaunchContext( + runtime_type=runtime_type, + project_dir=project_dir, + detection=detection, + config=dict(detection.raw_config or {}), + ), + build_id=build.id, + agent_id=build.agent_id, + model=selected_model, + request_config=request_config, + manifest_sha256=build.resolved_digest, + ) + + @staticmethod + def _select_model(runtime_lock: dict, requested: str | None) -> str: + allowed = [str(value) for value in runtime_lock.get("models") or [] if str(value)] + default = str(runtime_lock.get("model") or "").strip() + if default and default not in allowed: + allowed.insert(0, default) + selected = str(requested or default).strip() + if not selected: + raise StudioError( + "AGENT_MODEL_REQUIRED", + "Build 没有绑定可运行模型", + status_code=422, + ) + if allowed and selected not in allowed: + raise StudioError( + "MODEL_NOT_BOUND", + "请求模型未绑定到当前 Agent Build", + status_code=422, + details={"model": selected, "allowedModels": allowed}, + ) + return selected + + +__all__ = ["FrameworkRunSpecResolver"] diff --git a/ksadk/studio/identifiers.py b/ksadk/studio/identifiers.py new file mode 100644 index 00000000..beee160e --- /dev/null +++ b/ksadk/studio/identifiers.py @@ -0,0 +1,33 @@ +"""Server-owned local identifiers for Studio resources.""" + +from __future__ import annotations + +import re +import secrets +from collections.abc import Callable + +from ksadk.studio.errors import StudioError + +_GENERATED_AGENT_SLUG = re.compile(r"^agentkit-[0-9a-f]{8}$") + + +def generate_agent_slug(exists: Callable[[str], bool]) -> str: + """Allocate a short opaque Agent ID without trusting a client-side default.""" + + for _attempt in range(32): + candidate = f"agentkit-{secrets.token_hex(4)}" + if not exists(candidate): + return candidate + raise StudioError( + "AGENT_ID_EXHAUSTED", + "无法分配唯一的本地标识", + status_code=503, + field="slug", + ) + + +def is_generated_agent_slug(value: str) -> bool: + return bool(_GENERATED_AGENT_SLUG.fullmatch(str(value or "").strip())) + + +__all__ = ["generate_agent_slug", "is_generated_agent_slug"] diff --git a/ksadk/studio/mcp_runtime.py b/ksadk/studio/mcp_runtime.py new file mode 100644 index 00000000..c9b0ad38 --- /dev/null +++ b/ksadk/studio/mcp_runtime.py @@ -0,0 +1,229 @@ +"""Official MCP SDK adapter for local stdio / streamable-http / SSE probe and Tool execution.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from datetime import timedelta +from pathlib import Path +from typing import Any, AsyncIterator + +import anyio +from mcp import ClientSession +from mcp.client.sse import sse_client +from mcp.client.stdio import ( + StdioServerParameters, + get_default_environment, + stdio_client, +) +from mcp.client.streamable_http import streamablehttp_client + +from ksadk.studio.contracts import MCPServerRef, ToolContract +from ksadk.studio.errors import StudioError +from ksadk.studio.model_client import CredentialResolver +from ksadk.studio.workspace import Workspace + +_DENIED_COMMANDS = {"bash", "cmd", "powershell", "pwsh", "sh", "zsh"} +_DENIED_EVAL_ARGS = {"-c", "-e", "--eval"} + + +class MCPRuntimeAdapter: + def __init__( + self, + workspace: Workspace, + *, + credentials: CredentialResolver | None = None, + ) -> None: + self.workspace = workspace + self.credentials = credentials or CredentialResolver() + + async def probe( + self, + server: MCPServerRef, + *, + timeout_seconds: int = 10, + ) -> dict[str, Any]: + self._validate(server) + try: + with anyio.fail_after(timeout_seconds): + async with self._session(server) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + initialized = await session.initialize() + result = await session.list_tools() + except StudioError: + raise + except Exception as exc: + detail = str(exc) or repr(exc) + if hasattr(exc, "exceptions"): + detail += " :: " + " | ".join(str(e) or repr(e) for e in exc.exceptions) + raise StudioError( + "MCP_PROBE_FAILED", + "MCP Server 探测失败", + status_code=422, + details={"server": server.name, "errorType": type(exc).__name__, "detail": detail}, + ) from exc + tools = [ + ToolContract( + name=tool.name, + version=server.version, + description=tool.description or "", + input_schema=tool.inputSchema, + output_schema={"type": "object"}, + executor="mcp", + mcp_server=server.name, + ).model_dump(by_alias=True, exclude_none=True, mode="json") + for tool in result.tools + ] + return { + "serverInfo": initialized.serverInfo.model_dump( + by_alias=True, + exclude_none=True, + mode="json", + ), + "tools": tools, + "timeoutSeconds": timeout_seconds, + } + + async def call( + self, + server: MCPServerRef, + *, + tool_name: str, + arguments: dict[str, Any], + timeout_seconds: int, + ) -> dict[str, Any]: + self._validate(server) + try: + async with self._session(server) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + result = await session.call_tool( + tool_name, + arguments, + read_timeout_seconds=timedelta(seconds=timeout_seconds), + ) + except StudioError: + raise + except Exception as exc: + raise StudioError( + "TOOL_EXECUTION_FAILED", + "MCP Tool 执行失败", + status_code=502, + details={ + "server": server.name, + "tool": tool_name, + "errorType": type(exc).__name__, + }, + ) from exc + if result.isError: + raise StudioError( + "TOOL_EXECUTION_FAILED", + "MCP Tool 返回错误", + status_code=502, + details={"server": server.name, "tool": tool_name}, + ) + return { + "content": [ + item.model_dump(by_alias=True, exclude_none=True, mode="json") + for item in result.content + ], + "structuredContent": result.structuredContent, + "isError": False, + } + + @asynccontextmanager + async def _session( + self, + server: MCPServerRef, + ) -> AsyncIterator[tuple[Any, Any]]: + transport = (server.transport or "stdio").lower() + if transport == "stdio": + params = self._stdio_params(server) + async with stdio_client(params) as (read_stream, write_stream): + yield read_stream, write_stream + elif transport in {"http", "streamable-http", "streamable_http"}: + headers = self._http_headers(server) + async with streamablehttp_client( + server.endpoint_url or "", headers=headers + ) as (read_stream, write_stream, _): + yield read_stream, write_stream + elif transport == "sse": + headers = self._http_headers(server) + async with sse_client(server.endpoint_url or "", headers=headers) as ( + read_stream, + write_stream, + ): + yield read_stream, write_stream + else: + raise StudioError( + "MCP_TRANSPORT_UNSUPPORTED", + f"不支持的 MCP transport: {transport}", + status_code=501, + details={"transport": transport}, + ) + + def _stdio_params(self, server: MCPServerRef) -> StdioServerParameters: + assert server.command + environment = get_default_environment() + for name, reference in server.env_refs.items(): + environment[name] = self.credentials.resolve(reference) + return StdioServerParameters( + command=server.command, + args=server.args, + env=environment, + cwd=self.workspace.root, + ) + + def _http_headers(self, server: MCPServerRef) -> dict[str, str]: + headers: dict[str, str] = {} + for name, reference in (server.env_refs or {}).items(): + try: + value = self.credentials.resolve(reference) + except StudioError as exc: + raise StudioError( + "MCP_CREDENTIAL_MISSING", + f"MCP Server 引用的凭证未配置:{reference}(请先保存该环境变量的值)", + status_code=422, + details={"server": server.name, "reference": reference}, + ) from exc + if name.lower() == "authorization": + if not value.lower().startswith(("bearer ", "basic ")): + value = f"Bearer {value}" + headers[name] = value + return headers + + @staticmethod + def _validate(server: MCPServerRef) -> None: + transport = (server.transport or "stdio").lower() + if transport == "stdio": + command = Path(server.command or "").name.lower() + if command in _DENIED_COMMANDS: + raise StudioError( + "MCP_COMMAND_DENIED", + "MCP command 不能直接启动 shell", + status_code=403, + details={"command": command}, + ) + if command in {"node", "python", "python3"} and any( + argument in _DENIED_EVAL_ARGS for argument in server.args + ): + raise StudioError( + "MCP_COMMAND_DENIED", + "MCP command 不允许使用内联 eval 参数", + status_code=403, + details={"command": command}, + ) + elif transport in {"http", "streamable-http", "streamable_http", "sse"}: + if not server.endpoint_url: + raise StudioError( + "MCP_TRANSPORT_UNSUPPORTED", + "HTTP/SSE MCP 必须配置 endpointUrl", + status_code=422, + details={"transport": transport}, + ) + else: + raise StudioError( + "MCP_TRANSPORT_UNSUPPORTED", + f"不支持的 MCP transport: {transport}", + status_code=501, + details={"transport": transport}, + ) diff --git a/ksadk/studio/model_client.py b/ksadk/studio/model_client.py new file mode 100644 index 00000000..43c6dc28 --- /dev/null +++ b/ksadk/studio/model_client.py @@ -0,0 +1,543 @@ +"""Safe OpenAI-compatible model client for local Studio runs.""" + +from __future__ import annotations + +import asyncio +import ipaddress +import os +import socket +from dataclasses import dataclass, field +from threading import RLock +from typing import Any, Awaitable, Callable +from urllib.parse import urlparse + +import httpx + +from ksadk.studio.contracts import NetworkPolicy, ResolvedModel, Usage +from ksadk.studio.errors import StudioError + +_DENIED_METADATA_HOSTS = { + "169.254.169.254", + "metadata.google.internal", + "metadata.azure.internal", + "fd00:ec2::254", +} + + +@dataclass(frozen=True) +class ToolCall: + id: str + name: str + arguments: str + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ModelResponse: + content: str + finish_reason: str + usage: Usage + tool_calls: list[ToolCall] + raw_message: dict[str, Any] + + +class CredentialResolver: + """Resolve immutable Secret references with a workspace-persisted overlay. + + Resolution order: in-memory session values → workspace secrets file + (``.agentkit/secrets.env``) → process environment (with paired fallback). + """ + + _FALLBACK_PAIRS: tuple[tuple[str, str], ...] = ( + ("AGENTKIT_MODEL_API_KEY", "OPENAI_API_KEY"), + ("OPENAI_API_KEY", "AGENTKIT_MODEL_API_KEY"), + ) + + def __init__(self, workspace: Any = None) -> None: + self._session_values: dict[str, bytearray] = {} + self._lock = RLock() + self._workspace = workspace + self._persisted: dict[str, str] | None = None + + def _secrets_path(self) -> Any | None: + if self._workspace is None: + return None + try: + return self._workspace.resolve(".agentkit/secrets.env") + except Exception: + return None + + def _load_persisted(self) -> dict[str, str]: + if self._persisted is not None: + return self._persisted + self._persisted = {} + path = self._secrets_path() + if path is not None and path.is_file(): + try: + for line in path.read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + if key: + self._persisted[key] = value + except Exception: + pass + return self._persisted + + def _write_persisted(self) -> None: + path = self._secrets_path() + if path is None: + return + lines = [f"{k}={v}" for k, v in sorted(self._persisted.items())] + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8") + try: + path.chmod(0o600) + except Exception: + pass + except Exception: + pass + + @staticmethod + def _validate_name(name: str) -> str: + allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" + if not name or any(char not in allowed for char in name): + raise StudioError( + "SECRET_REFERENCE_INVALID", + "环境变量 Secret 引用格式无效", + status_code=422, + field="credentialName", + ) + return name + + @classmethod + def _environment_name(cls, reference: str) -> str: + if not reference.startswith("env://"): + raise StudioError( + "SECRET_BACKEND_UNAVAILABLE", + "当前本地 Runtime 仅支持 env:// Secret 引用", + status_code=501, + details={"scheme": reference.partition("://")[0]}, + ) + return cls._validate_name(reference.removeprefix("env://")) + + @staticmethod + def _zero(value: bytearray | None) -> None: + if value is not None: + value[:] = b"\x00" * len(value) + + def put_session(self, name: str, value: str) -> dict[str, str | bool]: + name = self._validate_name(name) + if ( + not value + or len(value) > 16_384 + or value != value.strip() + or any(ord(char) < 32 or ord(char) == 127 for char in value) + ): + raise StudioError( + "SECRET_VALUE_INVALID", + "凭证不能为空、包含控制字符或超过 16 KiB", + status_code=422, + field="value", + ) + encoded = bytearray(value.encode("utf-8")) + with self._lock: + previous = self._session_values.get(name) + self._session_values[name] = encoded + self._zero(previous) + if self._secrets_path() is not None: + persisted = self._load_persisted() + if persisted.get(name) != value: + persisted[name] = value + self._write_persisted() + return self.status(f"env://{name}") + + def delete_session(self, name: str) -> dict[str, str | bool]: + name = self._validate_name(name) + with self._lock: + previous = self._session_values.pop(name, None) + self._zero(previous) + if self._secrets_path() is not None: + persisted = self._load_persisted() + if name in persisted: + persisted.pop(name, None) + self._write_persisted() + return self.status(f"env://{name}") + + def clear_session(self) -> None: + with self._lock: + values = list(self._session_values.values()) + self._session_values.clear() + for value in values: + self._zero(value) + + def _fallback_name(self, name: str) -> str | None: + for primary, alternate in self._FALLBACK_PAIRS: + if name == primary and alternate != primary: + return alternate + return None + + def _resolve_source(self, name: str) -> tuple[bool, str]: + """Return (configured, source) considering session, persisted file, env, and fallback.""" + fallback = self._fallback_name(name) + with self._lock: + if name in self._session_values: + return True, "session" + if fallback and fallback in self._session_values: + return True, "session-alias" + persisted = self._load_persisted() + if name in persisted: + return True, "workspace" + if fallback and fallback in persisted: + return True, "workspace-alias" + if os.environ.get(name): + return True, "environment" + if fallback and os.environ.get(fallback): + return True, "fallback" + return False, "missing" + + def _session_value(self, name: str) -> str | None: + with self._lock: + value = self._session_values.get(name) + return value.decode("utf-8") if value is not None else None + + def status(self, reference: str) -> dict[str, str | bool]: + name = self._environment_name(reference) + configured, source = self._resolve_source(name) + return { + "reference": reference, + "name": name, + "configured": configured, + "source": source, + "persistence": source, + } + + def resolve(self, reference: str) -> str: + name = self._environment_name(reference) + fallback = self._fallback_name(name) + aliases = [name, *([fallback] if fallback else [])] + for candidate in aliases: + value = self._session_value(candidate) + if value is not None: + return value + persisted = self._load_persisted() + for candidate in aliases: + if candidate in persisted: + return persisted[candidate] + for candidate in aliases: + value = os.environ.get(candidate) + if value: + return value + raise StudioError( + "SECRET_NOT_FOUND", + "模型凭证尚未配置", + status_code=422, + details={"reference": reference}, + ) + + def exists(self, reference: str) -> bool: + try: + return bool(self.status(reference)["configured"]) + except StudioError: + return False + + +class NetworkGuard: + async def check(self, endpoint_url: str, policy: NetworkPolicy) -> None: + parsed = urlparse(endpoint_url) + host = (parsed.hostname or "").lower().rstrip(".") + if parsed.scheme not in {"http", "https"} or not host: + raise StudioError( + "MODEL_ENDPOINT_INVALID", + "模型地址必须是有效的 HTTP(S) URL", + status_code=422, + ) + allowed = {value.lower().rstrip(".") for value in policy.allowed_hosts} + if policy.mode == "restricted" and host not in allowed: + raise StudioError( + "NETWORK_TARGET_DENIED", + "目标 hostname 不在网络允许清单中", + status_code=403, + details={"host": host}, + ) + if host in _DENIED_METADATA_HOSTS: + raise self._denied(host) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + try: + records = await asyncio.to_thread( + socket.getaddrinfo, + host, + port, + 0, + socket.SOCK_STREAM, + ) + except socket.gaierror as exc: + raise StudioError( + "NETWORK_TARGET_UNRESOLVED", + "模型 hostname 无法解析", + status_code=422, + details={"host": host}, + ) from exc + for record in records: + address = str(record[4][0]).split("%", 1)[0] + ip = ipaddress.ip_address(address) + if str(ip) in _DENIED_METADATA_HOSTS or ip.is_link_local or ip.is_multicast: + raise self._denied(host) + if ( + ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_unspecified + ) and not policy.allow_private_network: + raise self._denied(host) + + @staticmethod + def _denied(host: str) -> StudioError: + return StudioError( + "NETWORK_TARGET_DENIED", + "目标地址不满足本地 Runtime 网络策略", + status_code=403, + details={"host": host}, + ) + + +class OpenAICompatibleModelClient: + def __init__( + self, + *, + credential_resolver: CredentialResolver | None = None, + network_guard: NetworkGuard | None = None, + transport: httpx.AsyncBaseTransport | None = None, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + ) -> None: + self.credential_resolver = credential_resolver or CredentialResolver() + self.network_guard = network_guard or NetworkGuard() + self.transport = transport + self.sleep = sleep + + async def complete( + self, + model: ResolvedModel, + *, + messages: list[dict[str, Any]], + network_policy: NetworkPolicy, + timeout_seconds: int, + max_attempts: int, + backoff_seconds: float, + tools: list[dict[str, Any]] | None = None, + allow_empty: bool = False, + ) -> ModelResponse: + await self.network_guard.check(model.endpoint_url, network_policy) + credential = self.credential_resolver.resolve(model.credential_ref) + wire_api = (model.wire_api or "chat").strip().lower() + if wire_api == "responses": + payload = self._responses_payload(model, messages, tools) + else: + payload = { + "model": model.model, + "messages": messages, + "temperature": model.parameters.temperature, + "max_tokens": model.parameters.max_tokens, + "stream": False, + } + if model.parameters.top_p is not None: + payload["top_p"] = model.parameters.top_p + if tools: + payload["tools"] = tools + payload["tool_choice"] = "auto" + headers = { + "Authorization": f"Bearer {credential}", + "Content-Type": "application/json", + } + timeout = httpx.Timeout(timeout_seconds, connect=min(10, timeout_seconds)) + async with httpx.AsyncClient( + transport=self.transport, + timeout=timeout, + follow_redirects=False, + ) as client: + for attempt in range(1, max_attempts + 1): + try: + response = await client.post( + model.endpoint_url, + headers=headers, + json=payload, + ) + except (httpx.TimeoutException, httpx.TransportError) as exc: + if attempt >= max_attempts: + raise StudioError( + "MODEL_REQUEST_FAILED", + "模型请求网络失败", + status_code=502, + details={"attempts": attempt, "errorType": type(exc).__name__}, + ) from exc + await self.sleep(backoff_seconds * attempt) + continue + if response.is_redirect: + raise StudioError( + "NETWORK_TARGET_DENIED", + "模型 endpoint 不允许重定向", + status_code=403, + details={"statusCode": response.status_code}, + ) + if response.status_code == 429 or response.status_code >= 500: + if attempt < max_attempts: + await self.sleep(backoff_seconds * attempt) + continue + if response.status_code >= 400: + raise StudioError( + "MODEL_REQUEST_FAILED", + "模型服务返回错误", + status_code=502, + details={"upstreamStatus": response.status_code}, + ) + if wire_api == "responses": + return self._parse_responses_response(response, allow_empty=allow_empty) + return self._parse_response(response, allow_empty=allow_empty) + raise AssertionError("unreachable") + + @staticmethod + def _responses_payload( + model: ResolvedModel, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + ) -> dict[str, Any]: + """把 chat 语义消息映射到 Responses API 最小可用载荷。""" + if tools: + raise StudioError( + "MODEL_REQUEST_FAILED", + "Responses 端点暂不支持 tools 参数", + status_code=422, + ) + instructions: list[str] = [] + items: list[dict[str, Any]] = [] + for message in messages: + role = str(message.get("role") or "user") + text = str(message.get("content") or "") + if role == "system": + instructions.append(text) + continue + part_type = "output_text" if role == "assistant" else "input_text" + items.append({"role": role, "content": [{"type": part_type, "text": text}]}) + payload: dict[str, Any] = { + "model": model.model, + "input": items, + "max_output_tokens": model.parameters.max_tokens, + } + if instructions: + payload["instructions"] = "\n\n".join(instructions) + return payload + + @staticmethod + def _parse_responses_response( + response: httpx.Response, allow_empty: bool = False + ) -> ModelResponse: + if len(response.content) > 16 * 1024 * 1024: + raise StudioError( + "MODEL_RESPONSE_TOO_LARGE", + "模型响应超过 16 MiB 限制", + status_code=502, + ) + try: + payload = response.json() + except ValueError as exc: + raise StudioError( + "MODEL_RESPONSE_INVALID", + "模型响应不符合 Responses 协议", + status_code=502, + ) from exc + content = payload.get("output_text") + if not isinstance(content, str) or not content: + parts: list[str] = [] + for item in payload.get("output") or []: + if not isinstance(item, dict): + continue + for part in item.get("content") or []: + if isinstance(part, dict) and part.get("type") in {"output_text", "text"}: + parts.append(str(part.get("text") or "")) + content = "".join(parts) + status = str(payload.get("status") or "") + if not content and not allow_empty: + raise StudioError( + "MODEL_EMPTY_RESPONSE", + "模型未返回可用内容", + status_code=502, + details={"status": status}, + ) + raw_usage = payload.get("usage") or {} + usage = Usage( + input_tokens=int(raw_usage.get("input_tokens") or 0), + output_tokens=int(raw_usage.get("output_tokens") or 0), + total_tokens=int(raw_usage.get("total_tokens") or 0), + cached_input_tokens=int( + (raw_usage.get("input_tokens_details") or {}).get("cached_tokens") or 0 + ), + reasoning_output_tokens=int( + (raw_usage.get("output_tokens_details") or {}).get("reasoning_tokens") or 0 + ), + ) + return ModelResponse( + content=content, + finish_reason="stop" if status == "completed" else status, + usage=usage, + tool_calls=[], + raw_message={"output": payload.get("output")}, + ) + + @staticmethod + def _parse_response(response: httpx.Response, allow_empty: bool = False) -> ModelResponse: + if len(response.content) > 16 * 1024 * 1024: + raise StudioError( + "MODEL_RESPONSE_TOO_LARGE", + "模型响应超过 16 MiB 限制", + status_code=502, + ) + try: + payload = response.json() + choice = payload["choices"][0] + message = choice["message"] + except (ValueError, KeyError, IndexError, TypeError) as exc: + raise StudioError( + "MODEL_RESPONSE_INVALID", + "模型响应不符合 OpenAI-compatible 协议", + status_code=502, + ) from exc + calls = [] + for raw in message.get("tool_calls") or []: + function = raw.get("function") or {} + calls.append( + ToolCall( + id=str(raw.get("id") or ""), + name=str(function.get("name") or ""), + arguments=str(function.get("arguments") or "{}"), + raw=raw, + ) + ) + content = str(message.get("content") or "") + finish_reason = str(choice.get("finish_reason") or "") + if not content and not calls and not allow_empty: + raise StudioError( + "MODEL_EMPTY_RESPONSE", + "模型未返回可用内容", + status_code=502, + details={"finishReason": finish_reason}, + ) + raw_usage = payload.get("usage") or {} + usage = Usage( + input_tokens=int(raw_usage.get("prompt_tokens") or 0), + output_tokens=int(raw_usage.get("completion_tokens") or 0), + total_tokens=int(raw_usage.get("total_tokens") or 0), + cached_input_tokens=int( + (raw_usage.get("prompt_tokens_details") or {}).get("cached_tokens") or 0 + ), + reasoning_output_tokens=int( + (raw_usage.get("completion_tokens_details") or {}).get("reasoning_tokens") or 0 + ), + reported=bool(raw_usage), + source="model-provider" if raw_usage else None, + ) + return ModelResponse( + content=content, + finish_reason=finish_reason, + usage=usage, + tool_calls=calls, + raw_message=message, + ) diff --git a/ksadk/studio/model_profile_service.py b/ksadk/studio/model_profile_service.py new file mode 100644 index 00000000..d4e175f1 --- /dev/null +++ b/ksadk/studio/model_profile_service.py @@ -0,0 +1,214 @@ +"""Model Profile connection diagnostics.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any +from urllib.parse import urlparse + +import httpx + +from ksadk.studio.contracts import ModelSpec, NetworkPolicy +from ksadk.studio.errors import StudioError + + +async def test_model_profile_connection( + *, + catalog: Any, + model_client: Any, + resource_id: str, +) -> dict: + descriptor = catalog.get(resource_id) + if descriptor.kind != "model": + raise StudioError( + "RESOURCE_KIND_INVALID", + "连接测试只能用于 Model Profile", + status_code=422, + details={"resourceId": resource_id}, + ) + spec = ModelSpec.model_validate(descriptor.contract) + resolved = catalog.resolver.resolve_model(spec) + resolved.parameters = resolved.parameters.model_copy( + update={ + "temperature": 0, + "max_tokens": min(resolved.parameters.max_tokens, 64), + } + ) + host = (urlparse(resolved.endpoint_url).hostname or "").lower().rstrip(".") + started = time.monotonic() + response = await model_client.complete( + resolved, + messages=[{"role": "user", "content": "这是连接测试。请只回复 OK。"}], + network_policy=NetworkPolicy( + mode="restricted", + allowed_hosts=[host] if host else [], + allow_private_network=False, + ), + timeout_seconds=20, + max_attempts=1, + backoff_seconds=0, + allow_empty=True, + ) + return { + "ok": True, + "resourceId": resource_id, + "model": resolved.model, + "finishReason": response.finish_reason, + "latencyMs": int((time.monotonic() - started) * 1000), + } + + +def _probe_candidates(raw_url: str) -> tuple[str, list[tuple[str, str]]]: + """把用户粘贴的任意形态地址归一化为 (base_url, [(protocol, endpoint_url), ...])。 + + 支持三种输入形态: + - https://host/v1/chat/completions → 直接候选 + - https://host/v1/responses → 直接候选 + - https://host 或 https://host/v1 → 推导 chat / responses 候选 + """ + url = raw_url.strip().rstrip("/") + if url.endswith("/chat/completions"): + base = url[: -len("/chat/completions")] + return base, [("chat", url)] + if url.endswith("/responses"): + base = url[: -len("/responses")] + return base, [("responses", url)] + if url.endswith("/v1"): + return url, [("chat", f"{url}/chat/completions"), ("responses", f"{url}/responses")] + return url, [ + ("chat", f"{url}/v1/chat/completions"), + ("responses", f"{url}/v1/responses"), + ("chat", f"{url}/chat/completions"), + ("responses", f"{url}/responses"), + ] + + +def _classify_probe(status_code: int) -> str: + if status_code == 200: + return "ok" + if status_code in {401, 403}: + return "auth_required" + if status_code in {400, 422, 429}: + return "recognized" + if status_code in {404, 405, 406}: + return "unavailable" + return "error" + + +async def probe_model_endpoint( + *, + url: str, + credential: str | None, + network_guard: Any = None, + timeout_seconds: float = 8.0, +) -> dict[str, Any]: + """智能探测模型端点:识别 chat / responses 协议、可用模型清单并给出推荐配置。""" + base, candidates = _probe_candidates(url) + host = (urlparse(base).hostname or "").lower().rstrip(".") + headers = {"Content-Type": "application/json"} + if credential: + headers["Authorization"] = f"Bearer {credential}" + + async def _post( + client: httpx.AsyncClient, + protocol: str, + endpoint: str, + probe_model: str, + ) -> dict[str, Any]: + if network_guard is not None: + await network_guard.check( + endpoint, + NetworkPolicy( + mode="restricted", + allowed_hosts=[host] if host else [], + allow_private_network=False, + ), + ) + payload: dict[str, Any] = ( + { + "model": probe_model, + "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 1, + "stream": False, + } + if protocol == "chat" + else {"model": probe_model, "input": "ping", "max_output_tokens": 16} + ) + started = time.monotonic() + try: + response = await client.post(endpoint, headers=headers, json=payload) + except (httpx.TimeoutException, httpx.TransportError) as exc: + return { + "protocol": protocol, + "endpointUrl": endpoint, + "status": "unreachable", + "errorType": type(exc).__name__, + } + return { + "protocol": protocol, + "endpointUrl": endpoint, + "status": _classify_probe(response.status_code), + "httpStatus": response.status_code, + "latencyMs": int((time.monotonic() - started) * 1000), + } + + async def _models(client: httpx.AsyncClient) -> list[str]: + models_urls = ( + (f"{base}/models", f"{base}/v1/models") + if not base.endswith("/v1") + else (f"{base}/models",) + ) + for models_url in models_urls: + try: + response = await client.get(models_url, headers=headers) + except (httpx.TimeoutException, httpx.TransportError): + continue + if response.status_code != 200: + continue + try: + data = response.json() + except ValueError: + continue + items = data.get("data") or data.get("models") or [] + names = [ + str(item.get("id") or item.get("name") or "") + for item in items + if isinstance(item, dict) + ] + names = [name for name in names if name] + if names: + return sorted(set(names))[:100] + return [] + + timeout = httpx.Timeout(timeout_seconds, connect=min(5.0, timeout_seconds)) + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: + # 先拿模型清单:部分网关对不存在的模型名直接 403,用真实模型探测才能区分协议 + models = await _models(client) + probe_model = models[0] if models else "__probe__" + attempts = await asyncio.gather( + *(_post(client, protocol, endpoint, probe_model) for protocol, endpoint in candidates) + ) + + recommended: dict[str, Any] | None = None + for wanted in ("ok", "auth_required", "recognized"): + hit = next((a for a in attempts if a["status"] == wanted), None) + if hit is not None: + recommended = { + "protocol": hit["protocol"], + "wireApi": "responses" if hit["protocol"] == "responses" else "chat", + "endpointUrl": hit["endpointUrl"], + "status": hit["status"], + } + break + + return { + "input": url, + "baseUrl": base, + "attempts": list(attempts), + "recommended": recommended, + "models": models, + } + + +__all__ = ["test_model_profile_connection", "probe_model_endpoint"] diff --git a/ksadk/studio/operations.py b/ksadk/studio/operations.py new file mode 100644 index 00000000..66015782 --- /dev/null +++ b/ksadk/studio/operations.py @@ -0,0 +1,243 @@ +"""Persistent asynchronous operations with idempotency and resumable events.""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Awaitable, Callable, cast +from uuid import uuid4 + +from pydantic import ValidationError + +from ksadk.studio.contracts import ( + Operation, + OperationEvent, + OperationKind, + OperationStatus, +) +from ksadk.studio.errors import StudioError, not_found +from ksadk.studio.workspace import Workspace + + +class OperationManager: + def __init__(self, workspace: Workspace) -> None: + self.workspace = workspace + self._tasks: dict[str, asyncio.Task] = {} + self._recover_interrupted() + + def submit( + self, + *, + kind: OperationKind, + resource_id: str, + idempotency_key: str, + runner: Callable[[], Awaitable[object]], + ) -> Operation: + existing = self._find_by_idempotency_key(idempotency_key) + if existing is not None: + return existing + operation = Operation( + id=f"op_{uuid4().hex}", + kind=kind, + resource_id=resource_id, + ) + self._write(operation, [], idempotency_key) + self.append(operation.id, "operation.queued", {"kind": kind}) + task = asyncio.create_task(self._run(operation.id, runner)) + self._tasks[operation.id] = task + + def remove_completed(_task: asyncio.Task, op_id: str = operation.id) -> None: + self._tasks.pop(op_id, None) + + task.add_done_callback(remove_completed) + return operation + + async def _run( + self, + operation_id: str, + runner: Callable[[], Awaitable[object]], + ) -> None: + operation = self.get(operation_id) + operation.status = OperationStatus.RUNNING + self._save_record(operation) + self.append(operation_id, "operation.started", {}) + try: + result = await runner() + result_id = getattr(result, "id", None) + if result_id: + operation.resource_id = str(result_id) + operation.status = OperationStatus.SUCCEEDED + operation.completed_at = datetime.now(timezone.utc) + self._save_record(operation) + self.append( + operation_id, + "operation.succeeded", + {"resourceId": operation.resource_id}, + ) + except asyncio.CancelledError: + operation.status = OperationStatus.CANCELLED + operation.completed_at = datetime.now(timezone.utc) + self._save_record(operation) + self.append(operation_id, "operation.cancelled", {}) + except StudioError as exc: + operation.status = OperationStatus.FAILED + operation.error = { + "code": exc.code, + "message": exc.message, + "field": exc.field, + } + operation.completed_at = datetime.now(timezone.utc) + self._save_record(operation) + self.append(operation_id, "operation.failed", operation.error) + except Exception: + operation.status = OperationStatus.FAILED + operation.error = { + "code": "INTERNAL_ERROR", + "message": "本地操作执行失败", + } + operation.completed_at = datetime.now(timezone.utc) + self._save_record(operation) + self.append(operation_id, "operation.failed", operation.error) + + def get(self, operation_id: str) -> Operation: + operation, _, _ = self._read(operation_id) + return operation + + def events(self, operation_id: str, *, after: int = 0) -> list[OperationEvent]: + _, events, _ = self._read(operation_id) + return [event for event in events if event.id > after] + + def append(self, operation_id: str, event_type: str, data: dict) -> OperationEvent: + operation, events, key = self._read(operation_id) + event = OperationEvent( + id=len(events) + 1, + operation_id=operation_id, + type=event_type, + data=data, + ) + events.append(event) + self._write(operation, events, key) + return event + + def cancel(self, operation_id: str) -> Operation: + operation = self.get(operation_id) + if operation.status in { + OperationStatus.SUCCEEDED, + OperationStatus.FAILED, + OperationStatus.CANCELLED, + OperationStatus.INTERRUPTED, + }: + return operation + task = self._tasks.get(operation_id) + if task is not None: + task.cancel() + return operation + + async def wait(self, operation_id: str, *, timeout: float = 30) -> Operation: + deadline = asyncio.get_running_loop().time() + timeout + while True: + operation = self.get(operation_id) + if operation.status in { + OperationStatus.SUCCEEDED, + OperationStatus.FAILED, + OperationStatus.CANCELLED, + OperationStatus.INTERRUPTED, + }: + return operation + if asyncio.get_running_loop().time() >= deadline: + raise TimeoutError(operation_id) + await asyncio.sleep(0.01) + + def _path(self, operation_id: str) -> Path: + return self.workspace.resolve( + Path(".agentkit/operations") / f"{operation_id}.json" + ) + + def _save_record(self, operation: Operation) -> None: + _, events, key = self._read(operation.id) + self._write(operation, events, key) + + def _write( + self, + operation: Operation, + events: list[OperationEvent], + idempotency_key: str, + ) -> None: + payload = { + "operation": operation.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), + "events": [ + event.model_dump(by_alias=True, exclude_none=True, mode="json") + for event in events + ], + "idempotencyKey": idempotency_key, + } + self.workspace.atomic_write_text( + self._path(operation.id), + json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + ) + + def _read( + self, operation_id: str + ) -> tuple[Operation, list[OperationEvent], str]: + path = self._path(operation_id) + if not path.is_file(): + raise not_found("operation", operation_id) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + operation = Operation.model_validate(payload["operation"]) + events = [ + OperationEvent.model_validate(item) for item in payload["events"] + ] + return operation, events, str(payload["idempotencyKey"]) + except (OSError, ValueError, KeyError, ValidationError) as exc: + raise StudioError( + "OPERATION_RECORD_INVALID", + "Operation 记录损坏", + status_code=500, + details={"id": operation_id}, + ) from exc + + def _find_by_idempotency_key(self, key: str) -> Operation | None: + directory = self.workspace.resolve(".agentkit/operations") + for path in directory.glob("op_*.json"): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + if payload.get("idempotencyKey") == key: + return cast(Operation, Operation.model_validate(payload["operation"])) + except (OSError, ValueError, KeyError, ValidationError): + continue + return None + + def _recover_interrupted(self) -> None: + directory = self.workspace.resolve(".agentkit/operations") + for path in directory.glob("op_*.json"): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + operation = Operation.model_validate(payload["operation"]) + events = [ + OperationEvent.model_validate(item) + for item in payload.get("events", []) + ] + key = str(payload["idempotencyKey"]) + except (OSError, ValueError, KeyError, ValidationError): + continue + if operation.status not in { + OperationStatus.QUEUED, + OperationStatus.RUNNING, + }: + continue + operation.status = OperationStatus.INTERRUPTED + operation.completed_at = datetime.now(timezone.utc) + events.append( + OperationEvent( + id=len(events) + 1, + operation_id=operation.id, + type="operation.interrupted", + data={"reason": "daemon_restart"}, + ) + ) + self._write(operation, events, key) diff --git a/ksadk/studio/otel_trace.py b/ksadk/studio/otel_trace.py new file mode 100644 index 00000000..af3be3be --- /dev/null +++ b/ksadk/studio/otel_trace.py @@ -0,0 +1,754 @@ +"""OTLP-first trace persistence and Studio read models. + +RuntimeEvent remains the live interaction contract. This module is the only +place that projects those events into telemetry: persisted Trace data is OTLP +JSON, while ``TraceView`` is a lossless, UI-oriented read model derived from +that canonical document. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Iterable +from urllib.parse import quote + +from ksadk.studio.contracts import RunEvent, RunRecord +from ksadk.studio.errors import StudioError, not_found +from ksadk.studio.pagination import keyset_page +from ksadk.studio.workspace import Workspace + +_TRACE_ID = re.compile(r"^[0-9a-f]{32}$") +_SPAN_KIND = {1: "INTERNAL", 2: "SERVER", 3: "CLIENT", 4: "PRODUCER", 5: "CONSUMER"} +_STATUS_CODE = {0: "UNSET", 1: "OK", 2: "ERROR"} +_SENSITIVE_KEYS = { + "args", + "command", + "content", + "cwd", + "input", + "output", + "prompt", + "runtimeEvent", + "secret", + "text", + "token", +} + +# 内容类字段:本地 Studio 默认放行(localhost 单用户调试),secret/token 永远脱敏 +_CONTENT_KEYS = {"args", "command", "content", "input", "output", "prompt", "text", "delta"} + + +def _trace_content_enabled() -> bool: + return os.environ.get("KSADK_STUDIO_TRACE_CONTENT", "1") != "0" + + +def _canonical_trace_id(value: str) -> str: + normalized = value.strip().lower() + if _TRACE_ID.fullmatch(normalized): + return normalized + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:32] + + +def _span_id(trace_id: str, identity: str) -> str: + return hashlib.sha256(f"{trace_id}:{identity}".encode()).hexdigest()[:16] + + +def _unix_nano(value: datetime) -> int: + normalized = value.astimezone(timezone.utc) + return int(normalized.timestamp()) * 1_000_000_000 + normalized.microsecond * 1000 + + +def _iso(value: datetime | None) -> str | None: + if value is None: + return None + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _stringify(value: Any) -> str: + if isinstance(value, str): + return value + try: + return json.dumps(value, ensure_ascii=False, default=str) + except Exception: + return str(value) + + +def _truncate(text: str, limit: int = 4096) -> str: + if len(text) <= limit: + return text + return text[:limit] + "…" + + +def _otlp_value(value: Any) -> dict[str, Any]: + if isinstance(value, bool): + return {"boolValue": value} + if isinstance(value, int): + return {"intValue": str(value)} + if isinstance(value, float): + return {"doubleValue": value} + return {"stringValue": str(value)} + + +def _attributes(values: dict[str, Any]) -> list[dict[str, Any]]: + return [ + {"key": key, "value": _otlp_value(value)} + for key, value in sorted(values.items()) + if value is not None and value != "" + ] + + +def _decode_value(value: dict[str, Any]) -> Any: + if "intValue" in value: + return int(value["intValue"]) + if "doubleValue" in value: + return float(value["doubleValue"]) + if "boolValue" in value: + return bool(value["boolValue"]) + if "arrayValue" in value: + return [_decode_value(item) for item in value["arrayValue"].get("values", [])] + return value.get("stringValue", "") + + +def _decoded_attributes(items: Iterable[dict[str, Any]]) -> dict[str, Any]: + return {item["key"]: _decode_value(item["value"]) for item in items} + + +def _safe_event_attributes(data: dict[str, Any]) -> dict[str, Any]: + safe: dict[str, Any] = {} + include_content = _trace_content_enabled() + for key, value in data.items(): + if any(token in key.lower() for token in ("secret", "token")): + continue + if key in _SENSITIVE_KEYS: + if ( + include_content + and key in _CONTENT_KEYS + and isinstance(value, (str, int, float, bool)) + ): + safe[f"agentkit.event.{key}"] = _truncate(_stringify(value), 2048) + continue + if isinstance(value, (str, int, float, bool)): + safe[f"agentkit.event.{key}"] = value + return safe + + +def _duration_from(data: dict[str, Any]) -> int | None: + value = data.get("durationMs", data.get("duration_ms")) + return max(0, int(value)) if value is not None else None + + +def _enum_string(value: Any) -> str: + """Normalize both validated strings and assignment-time ``str`` enums.""" + + return str(getattr(value, "value", value)) + + +class OtlpTraceStore: + """Persist one canonical OTLP JSON document per local Trace.""" + + def __init__(self, workspace: Workspace) -> None: + self.workspace = workspace + + def _path(self, trace_id: str) -> Path: + canonical = _canonical_trace_id(trace_id) + return self.workspace.resolve(Path(".agentkit/traces") / f"{canonical}.otlp.json") + + def sync(self, record: RunRecord, events: list[RunEvent]) -> dict[str, Any]: + payload = self._build_otlp(record, events) + self.workspace.atomic_write_text( + self._path(record.trace_id), + json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + ) + return payload + + def get_otlp(self, trace_id: str) -> dict[str, Any]: + path = self._path(trace_id) + if not path.is_file(): + raise not_found("trace", trace_id) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise not_found("trace", trace_id) from exc + if not isinstance(payload, dict) or not isinstance(payload.get("resourceSpans"), list): + raise not_found("trace", trace_id) + return payload + + def get_trace_view(self, trace_id: str) -> dict[str, Any]: + raw = self.get_otlp(trace_id) + spans = self._spans(raw) + if not spans: + raise not_found("trace", trace_id) + root = next((span for span in spans if not span.get("parentSpanId")), spans[0]) + root_attributes = _decoded_attributes(root.get("attributes", [])) + canonical = root["traceId"] + duration = root_attributes.get("agentkit.duration.ms") + usage_reported = bool(root_attributes.get("agentkit.usage.reported", False)) + metrics = { + "durationMs": int(duration) if duration is not None else None, + "durationSource": root_attributes.get("agentkit.duration.source"), + "inputTokens": ( + int(root_attributes["gen_ai.usage.input_tokens"]) + if usage_reported and "gen_ai.usage.input_tokens" in root_attributes + else None + ), + "outputTokens": ( + int(root_attributes["gen_ai.usage.output_tokens"]) + if usage_reported and "gen_ai.usage.output_tokens" in root_attributes + else None + ), + "totalTokens": ( + int(root_attributes["agentkit.usage.total_tokens"]) + if usage_reported and "agentkit.usage.total_tokens" in root_attributes + else None + ), + "cachedInputTokens": ( + int(root_attributes.get("gen_ai.usage.cached_input_tokens", 0)) + if usage_reported + else None + ), + "reasoningOutputTokens": ( + int(root_attributes.get("gen_ai.usage.reasoning_tokens", 0)) + if usage_reported + else None + ), + "usageReported": usage_reported, + "usageSource": root_attributes.get("agentkit.usage.source"), + } + first_resource = (raw.get("resourceSpans") or [{}])[0] + first_scope = (first_resource.get("scopeSpans") or [{}])[0] + return { + "traceId": canonical, + "rootSpanId": root["spanId"], + "runId": root_attributes.get("agentkit.run.id", ""), + "agentId": root_attributes.get("gen_ai.agent.id", ""), + "sessionId": root_attributes.get("gen_ai.conversation.id", ""), + "runtimeType": root_attributes.get("agentkit.runtime.type", ""), + "model": root_attributes.get("gen_ai.request.model", ""), + "status": root_attributes.get("agentkit.run.status", ""), + "startedAt": root_attributes.get("agentkit.run.started_at"), + "target": {"type": "local", "name": "本地工作区"}, + "resource": _decoded_attributes( + (first_resource.get("resource") or {}).get("attributes", []) + ), + "scope": dict(first_scope.get("scope") or {}), + "metrics": metrics, + "spans": [self._span_view(span) for span in spans], + "rawOtlpPath": f"/api/v1/traces/{quote(canonical, safe='')}/otlp", + } + + def list_trace_summaries( + self, + *, + agent_id: str | None = None, + status: str | None = None, + limit: int = 200, + ) -> list[dict[str, Any]]: + summaries = self._trace_summaries(agent_id=agent_id, status=status) + summaries.sort( + key=lambda item: (item["startedAt"] or "", item["traceId"]), + reverse=True, + ) + return summaries[: max(1, min(limit, 1000))] + + def paginate_trace_summaries( + self, + *, + agent_id: str | None = None, + status: str | None = None, + query: str = "", + limit: int = 50, + cursor: str | None = None, + sort: str = "startedAt:desc", + ) -> dict[str, Any]: + if sort not in {"startedAt:desc", "startedAt:asc"}: + raise StudioError( + "PAGINATION_SORT_INVALID", + "Trace 排序字段无效", + status_code=422, + field="sort", + ) + summaries = self._trace_summaries( + agent_id=agent_id, + status=status, + query=query, + ) + filters = { + "agentId": agent_id or "", + "status": status or "", + "query": query.strip().lower(), + } + return keyset_page( + summaries, + key=lambda item: (item["startedAt"] or "", item["traceId"]), + reverse=sort.endswith(":desc"), + limit=max(1, min(limit, 1000)), + cursor=cursor, + namespace="traces", + sort=sort, + filters=filters, + ) + + def _trace_summaries( + self, + *, + agent_id: str | None = None, + status: str | None = None, + query: str = "", + ) -> list[dict[str, Any]]: + directory = self.workspace.resolve(".agentkit/traces") + summaries: list[dict[str, Any]] = [] + normalized_query = query.strip().lower() + for path in directory.glob("*.otlp.json"): + try: + view = self.get_trace_view(path.name.removesuffix(".otlp.json")) + except Exception: # noqa: BLE001 - a corrupt local trace must not hide valid traces + continue + if agent_id and view["agentId"] != agent_id: + continue + if status and view["status"] != status: + continue + if normalized_query and not any( + normalized_query in str(view.get(field) or "").lower() + for field in ( + "traceId", + "runId", + "agentId", + "sessionId", + "runtimeType", + "model", + "status", + ) + ): + continue + metrics = view["metrics"] + summaries.append( + { + "traceId": view["traceId"], + "runId": view["runId"], + "agentId": view["agentId"], + "sessionId": view["sessionId"], + "runtimeType": view["runtimeType"], + "model": view["model"], + "status": view["status"], + "startedAt": view["startedAt"], + "durationMs": metrics["durationMs"], + "durationSource": metrics["durationSource"], + "inputTokens": metrics["inputTokens"], + "outputTokens": metrics["outputTokens"], + "totalTokens": metrics["totalTokens"], + "usageReported": metrics["usageReported"], + "spanCount": len(view["spans"]), + "target": view["target"], + } + ) + return summaries + + def trace_overview( + self, + *, + range_name: str = "24h", + agent_id: str | None = None, + status: str | None = None, + now: datetime | None = None, + ) -> dict[str, Any]: + if range_name not in {"24h", "7d"}: + raise StudioError( + "TRACE_RANGE_INVALID", + "Trace 时间范围无效", + status_code=422, + field="range", + ) + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + if range_name == "24h": + bucket_delta = timedelta(hours=1) + bucket_count = 24 + bucket_start = current.replace(minute=0, second=0, microsecond=0) + else: + bucket_delta = timedelta(days=1) + bucket_count = 7 + bucket_start = current.replace(hour=0, minute=0, second=0, microsecond=0) + starts = [bucket_start - bucket_delta * index for index in range(bucket_count - 1, -1, -1)] + buckets = [ + { + "startedAt": _iso(start), + "runs": 0, + "completed": 0, + } + for start in starts + ] + cutoff = starts[0] + summaries = self._trace_summaries(agent_id=agent_id, status=status) + in_range: list[dict[str, Any]] = [] + for item in summaries: + raw_started_at = item.get("startedAt") + if not raw_started_at: + continue + try: + started_at = datetime.fromisoformat( + str(raw_started_at).replace("Z", "+00:00") + ).astimezone(timezone.utc) + except ValueError: + continue + if started_at < cutoff or started_at > current: + continue + index = int((started_at - cutoff) // bucket_delta) + if index < 0 or index >= bucket_count: + continue + in_range.append(item) + buckets[index]["runs"] += 1 + if item["status"] == "COMPLETED": + buckets[index]["completed"] += 1 + + completed = [item for item in in_range if item["status"] == "COMPLETED"] + durations = [ + int(item["durationMs"]) for item in completed if item.get("durationMs") is not None + ] + total = len(in_range) + return { + "range": range_name, + "total": total, + "completed": len(completed), + "successRate": len(completed) / total if total else None, + "averageDurationMs": (sum(durations) / len(durations) if durations else None), + "inputTokens": sum(int(item.get("inputTokens") or 0) for item in in_range), + "outputTokens": sum(int(item.get("outputTokens") or 0) for item in in_range), + "totalTokens": sum(int(item.get("totalTokens") or 0) for item in in_range), + "buckets": buckets, + } + + def delete( + self, + trace_id: str, + *, + purge: bool, + trash_directory: Path | None = None, + ) -> None: + path = self._path(trace_id) + if not path.is_file(): + return + if purge: + path.unlink() + return + if trash_directory is None: + raise ValueError("recoverable deletion requires a trash directory") + destination = self.workspace.resolve(trash_directory / "traces" / path.name) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(path), str(destination)) + + def _build_otlp(self, record: RunRecord, events: list[RunEvent]) -> dict[str, Any]: + trace_id = _canonical_trace_id(record.trace_id) + root_id = _span_id(trace_id, "root") + started_at = record.started_at or ( + events[0].created_at if events else datetime.now(timezone.utc) + ) + root_start = _unix_nano(started_at) + root_end = ( + root_start + record.duration_ms * 1_000_000 + if record.duration_ms is not None + else _unix_nano(record.completed_at) + if record.completed_at is not None + else _unix_nano(events[-1].created_at) + if events + else root_start + ) + run_status = _enum_string(record.status) + root_attributes: dict[str, Any] = { + "gen_ai.operation.name": "invoke_agent", + "gen_ai.agent.id": record.agent_id, + "gen_ai.agent.name": record.agent_id, + "gen_ai.conversation.id": record.session_id, + "gen_ai.request.model": record.model, + "agentkit.run.id": record.id, + "agentkit.build.id": record.build_id, + "agentkit.run.status": run_status, + "agentkit.run.started_at": _iso(record.started_at), + "agentkit.runtime.type": record.runtime_type, + "agentkit.manifest.sha256": record.manifest_sha256, + "agentkit.usage.reported": record.usage.reported, + "agentkit.usage.source": record.usage.source, + "agentkit.duration.ms": record.duration_ms, + "agentkit.duration.source": record.duration_source, + } + if record.usage.reported: + root_attributes.update( + { + "gen_ai.usage.input_tokens": record.usage.input_tokens, + "gen_ai.usage.output_tokens": record.usage.output_tokens, + "gen_ai.usage.cached_input_tokens": record.usage.cached_input_tokens, + "gen_ai.usage.reasoning_tokens": record.usage.reasoning_output_tokens, + "agentkit.usage.total_tokens": record.usage.total_tokens, + } + ) + root = { + "traceId": trace_id, + "spanId": root_id, + "name": f"invoke_agent {record.agent_id}", + "kind": 1, + "startTimeUnixNano": str(root_start), + "endTimeUnixNano": str(root_end), + "attributes": _attributes(root_attributes), + "events": self._root_events(events), + "status": { + "code": ( + 1 + if run_status == "COMPLETED" + else 2 + if run_status in {"FAILED", "TIMED_OUT", "CANCELLED"} + else 0 + ) + }, + } + spans = [ + root, + *self._model_spans(trace_id, root_id, events, root_end), + *self._tool_spans(trace_id, root_id, events, root_end), + ] + spans.sort(key=lambda span: (int(span["startTimeUnixNano"]), span["spanId"])) + return { + "resourceSpans": [ + { + "resource": { + "attributes": _attributes( + { + "service.name": "agentkit-studio", + "service.namespace": "ksadk", + "deployment.environment.name": "local", + "agentkit.runtime.target": "local", + } + ) + }, + "scopeSpans": [ + { + "scope": {"name": "ksadk.studio.trace-adapter", "version": "1"}, + "spans": spans, + } + ], + } + ] + } + + @staticmethod + def _root_events(events: list[RunEvent]) -> list[dict[str, Any]]: + excluded = { + "proxy.requested", + "proxy.completed", + "model.requested", + "model.completed", + "command.started", + "command.completed", + "tool.started", + "tool.requested", + "tool.completed", + } + return [ + { + "timeUnixNano": str(_unix_nano(event.created_at)), + "name": event.type, + "attributes": _attributes(_safe_event_attributes(event.data)), + } + for event in events + if event.type not in excluded + ] + + def _model_spans( + self, trace_id: str, root_id: str, events: list[RunEvent], root_end: int + ) -> list[dict[str, Any]]: + starts: list[RunEvent] = [] + by_response: dict[str, RunEvent] = {} + spans: list[dict[str, Any]] = [] + for event in events: + if event.type in {"proxy.requested", "model.requested"}: + starts.append(event) + response_id = str(event.data.get("responseId") or "") + if response_id: + by_response[response_id] = event + continue + if event.type not in {"proxy.completed", "model.completed"}: + continue + response_id = str(event.data.get("responseId") or "") + start = by_response.pop(response_id, None) if response_id else None + if start is None and starts: + start = starts.pop(0) + elif start in starts: + starts.remove(start) + if start is None: + continue + spans.append(self._model_span(trace_id, root_id, start, event, root_end)) + for start in starts: + spans.append(self._model_span(trace_id, root_id, start, None, root_end)) + return spans + + @staticmethod + def _model_span( + trace_id: str, + root_id: str, + start: RunEvent, + end: RunEvent | None, + root_end: int, + ) -> dict[str, Any]: + model = str(start.data.get("model") or "unknown") + response_id = str(start.data.get("responseId") or "") + start_ns = _unix_nano(start.created_at) + duration = _duration_from(end.data) if end is not None else None + end_ns = ( + start_ns + duration * 1_000_000 + if duration is not None + else _unix_nano(end.created_at) + if end is not None + else root_end + ) + attrs: dict[str, Any] = { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": model, + "gen_ai.response.id": response_id, + } + if end is not None: + raw_usage = end.data.get("usage") + usage: dict[str, Any] = raw_usage if isinstance(raw_usage, dict) else {} + for source_key, target_key in ( + ("inputTokens", "gen_ai.usage.input_tokens"), + ("outputTokens", "gen_ai.usage.output_tokens"), + ("cachedInputTokens", "gen_ai.usage.cached_input_tokens"), + ("reasoningOutputTokens", "gen_ai.usage.reasoning_tokens"), + ): + if usage.get(source_key) is not None: + attrs[target_key] = int(usage[source_key]) + status_code = int(end.data.get("statusCode") or 200) if end is not None else 0 + return { + "traceId": trace_id, + "spanId": _span_id(trace_id, f"model:{start.id}:{response_id}"), + "parentSpanId": root_id, + "name": f"chat {model}", + "kind": 3, + "startTimeUnixNano": str(start_ns), + "endTimeUnixNano": str(max(start_ns, end_ns)), + "attributes": _attributes(attrs), + "events": [], + "status": {"code": 1 if 200 <= status_code < 400 else 2 if status_code else 0}, + } + + def _tool_spans( + self, trace_id: str, root_id: str, events: list[RunEvent], root_end: int + ) -> list[dict[str, Any]]: + starts: dict[str, RunEvent] = {} + spans: list[dict[str, Any]] = [] + for event in events: + if event.type in {"command.started", "tool.started", "tool.requested"}: + call_id = str(event.data.get("callId") or event.data.get("call_id") or event.id) + starts[call_id] = event + continue + if event.type not in {"command.completed", "tool.completed"}: + continue + call_id = str(event.data.get("callId") or event.data.get("call_id") or "") + start = starts.pop(call_id, None) + if start is None: + continue + spans.append(self._tool_span(trace_id, root_id, call_id, start, event, root_end)) + for call_id, start in starts.items(): + spans.append(self._tool_span(trace_id, root_id, call_id, start, None, root_end)) + return spans + + @staticmethod + def _tool_span( + trace_id: str, + root_id: str, + call_id: str, + start: RunEvent, + end: RunEvent | None, + root_end: int, + ) -> dict[str, Any]: + name = str( + start.data.get("tool") + or ("codex.command" if start.type == "command.started" else "tool") + ) + start_ns = _unix_nano(start.created_at) + duration = _duration_from(end.data) if end is not None else None + end_ns = ( + start_ns + duration * 1_000_000 + if duration is not None + else _unix_nano(end.created_at) + if end is not None + else root_end + ) + exit_code = end.data.get("exitCode") if end is not None else None + status = str(end.data.get("status") or "") if end is not None else "" + failed = (exit_code not in (None, 0)) or status.lower() in {"failed", "error"} + include_content = _trace_content_enabled() + tool_input: Any = None + if include_content: + if start.type == "command.started": + tool_input = start.data.get("command") or start.data.get("commandActions") + elif start.type == "tool.started": + tool_input = ( + start.data.get("args") or start.data.get("arguments") or start.data.get("input") + ) + tool_output: Any = None + if include_content and end is not None: + tool_output = ( + end.data.get("result") or end.data.get("output") or end.data.get("content") + ) + attrs: dict[str, Any] = { + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": name, + "gen_ai.tool.call.id": call_id, + "agentkit.tool.exit_code": exit_code, + "agentkit.tool.status": status, + } + if tool_input is not None: + attrs["agentkit.tool.input"] = _truncate(_stringify(tool_input)) + if tool_output is not None: + attrs["agentkit.tool.output"] = _truncate(_stringify(tool_output)) + return { + "traceId": trace_id, + "spanId": _span_id(trace_id, f"tool:{call_id}:{start.id}"), + "parentSpanId": root_id, + "name": f"execute_tool {name}", + "kind": 1, + "startTimeUnixNano": str(start_ns), + "endTimeUnixNano": str(max(start_ns, end_ns)), + "attributes": _attributes(attrs), + "events": [], + "status": {"code": 2 if failed else 1 if end is not None else 0}, + } + + @staticmethod + def _spans(raw: dict[str, Any]) -> list[dict[str, Any]]: + spans: list[dict[str, Any]] = [] + for resource in raw.get("resourceSpans", []): + for scope in resource.get("scopeSpans", []): + spans.extend(scope.get("spans", [])) + return spans + + @staticmethod + def _span_view(span: dict[str, Any]) -> dict[str, Any]: + start_ns = int(span.get("startTimeUnixNano") or 0) + end_ns = int(span.get("endTimeUnixNano") or start_ns) + return { + "spanId": span["spanId"], + "parentSpanId": span.get("parentSpanId"), + "name": span.get("name", ""), + "kind": _SPAN_KIND.get(int(span.get("kind") or 0), "UNSPECIFIED"), + "status": _STATUS_CODE.get(int((span.get("status") or {}).get("code") or 0), "UNSET"), + "startTimeUnixNano": str(start_ns), + "endTimeUnixNano": str(end_ns), + "durationMs": max(0, round((end_ns - start_ns) / 1_000_000, 3)), + "attributes": _decoded_attributes(span.get("attributes", [])), + "events": [ + { + "name": event.get("name", ""), + "timeUnixNano": event.get("timeUnixNano", "0"), + "attributes": _decoded_attributes(event.get("attributes", [])), + } + for event in span.get("events", []) + ], + "links": span.get("links", []), + } + + +__all__ = ["OtlpTraceStore"] diff --git a/ksadk/studio/pagination.py b/ksadk/studio/pagination.py new file mode 100644 index 00000000..9fb561b4 --- /dev/null +++ b/ksadk/studio/pagination.py @@ -0,0 +1,182 @@ +"""Signed keyset cursors shared by Studio list endpoints. + +The cursor is intentionally opaque to clients. It binds the last stable sort +tuple to a namespace, a whitelisted sort name and the active filters so a +cursor cannot be replayed against a different query. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import secrets +from collections.abc import Callable, Mapping, Sequence +from typing import Any, TypeVar + +from ksadk.studio.errors import StudioError + +T = TypeVar("T") +CursorScalar = str | int | float | bool | None + +_CURSOR_VERSION = 1 +_CURSOR_SECRET = secrets.token_bytes(32) +_MAX_CURSOR_LENGTH = 4096 + + +def _invalid_cursor() -> StudioError: + return StudioError( + "PAGINATION_CURSOR_INVALID", + "分页游标无效或已过期", + status_code=422, + field="cursor", + ) + + +def _encode_part(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _decode_part(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + try: + return base64.b64decode(value + padding, altchars=b"-_", validate=True) + except (ValueError, TypeError) as exc: + raise _invalid_cursor() from exc + + +def _filter_fingerprint(filters: Mapping[str, Any]) -> str: + canonical = json.dumps( + dict(filters), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest()[:24] + + +def encode_cursor( + *, + namespace: str, + sort: str, + filters: Mapping[str, Any], + key: Sequence[CursorScalar], +) -> str: + """Return an authenticated opaque cursor for the last emitted row.""" + + if not key or len(key) > 16: + raise ValueError("cursor key must contain between 1 and 16 values") + payload = { + "v": _CURSOR_VERSION, + "ns": namespace, + "sort": sort, + "filters": _filter_fingerprint(filters), + "key": list(key), + } + encoded = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + signature = hmac.new(_CURSOR_SECRET, encoded, hashlib.sha256).digest() + return f"{_encode_part(encoded)}.{_encode_part(signature)}" + + +def decode_cursor( + cursor: str, + *, + namespace: str, + sort: str, + filters: Mapping[str, Any], +) -> tuple[CursorScalar, ...]: + """Validate a cursor and return its stable sort tuple.""" + + if not cursor or len(cursor) > _MAX_CURSOR_LENGTH: + raise _invalid_cursor() + try: + encoded_part, signature_part = cursor.split(".", 1) + encoded = _decode_part(encoded_part) + signature = _decode_part(signature_part) + except ValueError as exc: + raise _invalid_cursor() from exc + expected_signature = hmac.new(_CURSOR_SECRET, encoded, hashlib.sha256).digest() + if not hmac.compare_digest(signature, expected_signature): + raise _invalid_cursor() + try: + payload = json.loads(encoded.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise _invalid_cursor() from exc + if not isinstance(payload, dict) or set(payload) != { + "v", + "ns", + "sort", + "filters", + "key", + }: + raise _invalid_cursor() + if ( + payload.get("v") != _CURSOR_VERSION + or payload.get("ns") != namespace + or payload.get("sort") != sort + or payload.get("filters") != _filter_fingerprint(filters) + ): + raise _invalid_cursor() + key = payload.get("key") + if not isinstance(key, list) or not key or len(key) > 16: + raise _invalid_cursor() + if any(value is not None and not isinstance(value, (str, int, float, bool)) for value in key): + raise _invalid_cursor() + return tuple(key) + + +def keyset_page( + items: Sequence[T], + *, + key: Callable[[T], Sequence[CursorScalar]], + reverse: bool, + limit: int, + cursor: str | None, + namespace: str, + sort: str, + filters: Mapping[str, Any], +) -> dict[str, Any]: + """Sort and page records using a unique stable tuple. + + Callers must include a unique identifier as the final key component. + Filters are expected to have been applied before this function is called. + """ + + page_limit = max(1, limit) + ordered = sorted(items, key=lambda item: tuple(key(item)), reverse=reverse) + remaining = ordered + if cursor is not None: + cursor_key = decode_cursor( + cursor, + namespace=namespace, + sort=sort, + filters=filters, + ) + try: + remaining = [ + item + for item in ordered + if (tuple(key(item)) < cursor_key if reverse else tuple(key(item)) > cursor_key) + ] + except TypeError as exc: + raise _invalid_cursor() from exc + page_items = remaining[:page_limit] + next_cursor = None + if len(remaining) > page_limit and page_items: + next_cursor = encode_cursor( + namespace=namespace, + sort=sort, + filters=filters, + key=key(page_items[-1]), + ) + return { + "items": page_items, + "nextCursor": next_cursor, + "total": len(ordered), + } diff --git a/ksadk/studio/python_tool_inspection.py b/ksadk/studio/python_tool_inspection.py new file mode 100644 index 00000000..b6454bfc --- /dev/null +++ b/ksadk/studio/python_tool_inspection.py @@ -0,0 +1,183 @@ +"""AST-only, inspect-before-commit staging for uploaded Python tools.""" + +from __future__ import annotations + +import ast +import hashlib +import json +import re +import shutil +from pathlib import Path, PurePath +from typing import Any +from uuid import uuid4 + +from ksadk.studio.errors import StudioError +from ksadk.studio.workspace import Workspace + +_MAX_SOURCE_BYTES = 1024 * 1024 +_TOKEN = re.compile(r"^pyti_[0-9a-f]{32}$") + + +class PythonToolInspector: + """Stage Python source and expose declarations without importing it.""" + + def __init__(self, workspace: Workspace) -> None: + self.workspace = workspace + self.root = workspace.resolve(".agentkit/catalog/python-tool-inspections") + self.root.mkdir(parents=True, exist_ok=True) + + def inspect(self, content: bytes, *, filename: str) -> dict[str, Any]: + normalized_filename = str(filename or "").strip() + if ( + not normalized_filename + or PurePath(normalized_filename).name != normalized_filename + or not normalized_filename.endswith(".py") + ): + raise StudioError( + "PYTHON_TOOL_FILENAME_INVALID", + "Python Tool 必须是单个 .py 文件", + status_code=422, + field="file", + ) + if not content: + raise StudioError( + "PYTHON_TOOL_EMPTY", + "Python Tool 文件不能为空", + status_code=422, + field="file", + ) + if len(content) > _MAX_SOURCE_BYTES: + raise StudioError( + "PYTHON_TOOL_TOO_LARGE", + "Python Tool 源码不能超过 1 MiB", + status_code=413, + field="file", + ) + try: + source = content.decode("utf-8") + except UnicodeDecodeError as exc: + raise StudioError( + "PYTHON_TOOL_ENCODING_INVALID", + "Python Tool 源码必须使用 UTF-8 编码", + status_code=422, + field="file", + ) from exc + try: + tree = ast.parse(source, filename=normalized_filename) + except SyntaxError as exc: + raise StudioError( + "PYTHON_TOOL_SYNTAX_INVALID", + f"Python 语法错误:第 {exc.lineno or 1} 行", + status_code=422, + field="file", + details={"line": exc.lineno, "offset": exc.offset}, + ) from exc + + callables = [ + self._callable(node) + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and not node.name.startswith("_") + ] + if not callables: + raise StudioError( + "PYTHON_TOOL_CALLABLE_REQUIRED", + "文件中没有可导入的顶层公开函数", + status_code=422, + field="file", + ) + + token = f"pyti_{uuid4().hex}" + directory = self.root / token + directory.mkdir(parents=True, exist_ok=False) + digest = hashlib.sha256(content).hexdigest() + try: + self.workspace.atomic_write_text(directory / "tool.py", source) + record = { + "format": "agentkit.python-tool-inspection/v1", + "inspectionToken": token, + "filename": normalized_filename, + "sha256": digest, + "size": len(content), + "callables": callables, + } + self.workspace.atomic_write_text( + directory / "inspection.json", + json.dumps(record, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + ) + except BaseException: + shutil.rmtree(directory, ignore_errors=True) + raise + return dict(record) + + def load(self, token: str) -> dict[str, Any]: + directory = self._directory(token) + record_path = directory / "inspection.json" + source_path = directory / "tool.py" + try: + record = json.loads(record_path.read_text(encoding="utf-8")) + content = source_path.read_bytes() + except (OSError, ValueError) as exc: + raise StudioError( + "PYTHON_TOOL_INSPECTION_NOT_FOUND", + "Python Tool 检查不存在或已失效", + status_code=404, + ) from exc + actual = hashlib.sha256(content).hexdigest() + if actual != str(record.get("sha256") or ""): + raise StudioError( + "PYTHON_TOOL_INSPECTION_CHANGED", + "Python Tool 暂存内容在确认前发生变化,请重新检查", + status_code=409, + ) + record["sourcePath"] = self.workspace.relative(source_path) + return record + + def consume(self, token: str) -> None: + shutil.rmtree(self._directory(token), ignore_errors=True) + + def _directory(self, token: str) -> Path: + if not _TOKEN.fullmatch(str(token or "")): + raise StudioError( + "PYTHON_TOOL_INSPECTION_NOT_FOUND", + "Python Tool 检查不存在或已失效", + status_code=404, + ) + directory = (self.root / token).resolve() + try: + directory.relative_to(self.root.resolve()) + except ValueError as exc: + raise StudioError( + "PYTHON_TOOL_INSPECTION_NOT_FOUND", + "Python Tool 检查不存在或已失效", + status_code=404, + ) from exc + return directory + + @staticmethod + def _callable(node: ast.FunctionDef | ast.AsyncFunctionDef) -> dict[str, Any]: + positional = [*node.args.posonlyargs, *node.args.args] + required_count = max(0, len(positional) - len(node.args.defaults)) + required = [argument.arg for argument in positional[:required_count]] + required.extend( + argument.arg + for argument, default in zip(node.args.kwonlyargs, node.args.kw_defaults) + if default is None + ) + parameters = [argument.arg for argument in positional] + if node.args.vararg is not None: + parameters.append(f"*{node.args.vararg.arg}") + parameters.extend(argument.arg for argument in node.args.kwonlyargs) + if node.args.kwarg is not None: + parameters.append(f"**{node.args.kwarg.arg}") + return { + "name": node.name, + "async": isinstance(node, ast.AsyncFunctionDef), + "description": ast.get_docstring(node) or "", + "parameters": parameters, + "required": required, + "line": node.lineno, + } + + +__all__ = ["PythonToolInspector"] diff --git a/ksadk/studio/react-ui/components.json b/ksadk/studio/react-ui/components.json new file mode 100644 index 00000000..4b5fe42c --- /dev/null +++ b/ksadk/studio/react-ui/components.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui" + } +} diff --git a/ksadk/studio/react-ui/index.html b/ksadk/studio/react-ui/index.html new file mode 100644 index 00000000..97ef96b3 --- /dev/null +++ b/ksadk/studio/react-ui/index.html @@ -0,0 +1,12 @@ + + + + + + AgentKit Studio + + +
+ + + diff --git a/ksadk/studio/react-ui/package-lock.json b/ksadk/studio/react-ui/package-lock.json new file mode 100644 index 00000000..1059a34b --- /dev/null +++ b/ksadk/studio/react-ui/package-lock.json @@ -0,0 +1,5948 @@ +{ + "name": "@kingsoftcloud/agentkit-studio-react", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@kingsoftcloud/agentkit-studio-react", + "version": "0.1.0", + "dependencies": { + "@hookform/resolvers": "^5.7.1", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", + "@tanstack/react-table": "^9.1.2", + "@xyflow/react": "^12.11.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "lucide-react": "^1.7.0", + "prism-react-renderer": "^2.4.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-dropzone": "^20.1.0", + "react-easy-crop": "^5.5.7", + "react-hook-form": "^7.85.0", + "react-json-view-lite": "^2.5.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.1", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.3", + "typescript": "~5.9.3", + "vite": "^8.0.1", + "vitest": "^4.1.10" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.7.1.tgz", + "integrity": "sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^1.2.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=3.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.1.2.tgz", + "integrity": "sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==", + "license": "MIT", + "dependencies": { + "@tanstack/react-store": "^0.11.0", + "@tanstack/table-core": "9.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.1.2.tgz", + "integrity": "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz", + "integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-selector": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-4.1.0.tgz", + "integrity": "sha512-Io1mP8CI3zec5Bxy3P3TxdrKnt35Cm8vNIHnZsvyj43l4YFjD4NRInBp240S5bDJQ0EP1jnh7nCAwXsO818OCg==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-react": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz", + "integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/prism-react-renderer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", + "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", + "license": "MIT", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-dropzone": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-20.1.0.tgz", + "integrity": "sha512-id1t9JDYNQeFzzIfB5/C6TrpLchy29rTSDxsBH/pcxhILyv/6bSOTJwOlvUsTWXkC2GduuHIldDV6UQXNWfIuA==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.5", + "file-selector": "^4.1.0" + }, + "engines": { + "node": ">= 22" + }, + "peerDependencies": { + "@types/react": "*", + "react": ">= 18" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-easy-crop": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.7.tgz", + "integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.85.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.85.0.tgz", + "integrity": "sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-json-view-lite": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz", + "integrity": "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ksadk/studio/react-ui/package.json b/ksadk/studio/react-ui/package.json new file mode 100644 index 00000000..d742bd63 --- /dev/null +++ b/ksadk/studio/react-ui/package.json @@ -0,0 +1,59 @@ +{ + "name": "@kingsoftcloud/agentkit-studio-react", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "node --test src/*.test.mjs", + "test:api": "node --test src/api.test.mjs", + "test:ui": "vitest run" + }, + "dependencies": { + "@hookform/resolvers": "^5.7.1", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", + "@tanstack/react-table": "^9.1.2", + "@xyflow/react": "^12.11.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "lucide-react": "^1.7.0", + "prism-react-renderer": "^2.4.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-dropzone": "^20.1.0", + "react-easy-crop": "^5.5.7", + "react-hook-form": "^7.85.0", + "react-json-view-lite": "^2.5.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.1", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.3", + "typescript": "~5.9.3", + "vite": "^8.0.1", + "vitest": "^4.1.10" + } +} diff --git a/ksadk/studio/react-ui/postcss.config.js b/ksadk/studio/react-ui/postcss.config.js new file mode 100644 index 00000000..c2ddf748 --- /dev/null +++ b/ksadk/studio/react-ui/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/ksadk/studio/react-ui/src/App.tsx b/ksadk/studio/react-ui/src/App.tsx new file mode 100644 index 00000000..7347f4e9 --- /dev/null +++ b/ksadk/studio/react-ui/src/App.tsx @@ -0,0 +1,364 @@ +import { useCallback, useEffect, useState } from "react"; +import { apiFetch } from "./api"; +import { AgentsPage } from "./pages/AgentsPage"; +import { CreatePage } from "./pages/CreatePage"; +import { AgentDetailPage } from "./pages/AgentDetailPage"; +import { BuildsPage } from "./pages/BuildsPage"; +import { DeploymentsPage } from "./pages/DeploymentsPage"; +import { ResourcesPage, type ResourceKind } from "./pages/ResourcesPage"; +import { ObservabilityPage } from "./pages/ObservabilityPage"; +import { RuntimeResourcesPage } from "./pages/RuntimeResourcesPage"; +import { OrchestrationPage } from "./pages/OrchestrationPage"; +import { SettingsOverlay } from "./components/SettingsOverlay"; +import { ChatRunPanel } from "./components/ChatRunPanel"; +import { ChatWorkspace } from "./components/ChatWorkspace"; +import type { AgentAppearance } from "./components/AgentAvatar"; +import { ToastRegion } from "./components/Toast"; +import { StudioSelect } from "./components/ui/StudioSelect"; +import { useStudioViewportMode } from "./useStudioViewportMode"; +import { useStudioTheme } from "./useStudioTheme"; +import { + NavigationRail, + readNavigationRailPreference, + writeNavigationRailPreference, + type NavigationView, +} from "./components/NavigationRail"; +import { + Bot, ChevronDown, RefreshCw, PanelLeftClose, PanelLeftOpen, PanelRight, +} from "lucide-react"; + +type View = NavigationView; + +const VIEW_TITLE: Record = { + agents: "Agent", + create: "创建 Agent", + "agent-detail": "Agent 配置", + conversations: "会话", + resources: "工程资源", + builds: "构建", + deployments: "部署", + observability: "可观测", + "runtime-resources": "运行资源", + orchestration: "任务编排", +}; + +const VALID_VIEWS = Object.keys(VIEW_TITLE) as View[]; + +interface AgentSummary { + metadata: { id: string; name: string; revision?: number; labels?: Record; appearance?: AgentAppearance }; + spec?: { runtime?: { type?: string } }; + builds?: Array<{ id: string; status: string }>; +} + +export default function App() { + const viewportMode = useStudioViewportMode(); + const studioTheme = useStudioTheme(); + const [view, setViewState] = useState(() => { + const h = window.location.hash.replace(/^#\/?/, ""); + return VALID_VIEWS.includes(h as View) ? (h as View) : "agents"; + }); + const [resourceKind, setResourceKind] = useState("model"); + const [agents, setAgents] = useState([]); + const [agentsLoaded, setAgentsLoaded] = useState(false); + const [currentAgentId, setCurrentAgentId] = useState(""); + const [detailAgentId, setDetailAgentId] = useState(""); + const [editingAgentId, setEditingAgentId] = useState(""); + const [workspace, setWorkspace] = useState<{ name?: string; path?: string } | null>(null); + const [runtimeReady, setRuntimeReady] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [chatMounted, setChatMounted] = useState(view === "conversations"); + const [runPanelOpen, setRunPanelOpen] = useState(false); + const [refreshTick, setRefreshTick] = useState(0); + const [railExpandedPreference, setRailExpandedPreference] = useState(readNavigationRailPreference); + + useEffect(() => { + document.body.classList.toggle("create-mode", view === "create"); + return () => document.body.classList.remove("create-mode"); + }, [view]); + + useEffect(() => { + const syncViewFromHash = () => { + const hashView = window.location.hash.replace(/^#\/?/, "") as View; + if (VALID_VIEWS.includes(hashView)) setViewState(hashView); + }; + window.addEventListener("hashchange", syncViewFromHash); + window.addEventListener("popstate", syncViewFromHash); + return () => { + window.removeEventListener("hashchange", syncViewFromHash); + window.removeEventListener("popstate", syncViewFromHash); + }; + }, []); + + // hash 深链:#/agents 等,便于刷新定位 + function setView(v: View) { + setViewState(v); + window.history.replaceState(null, "", `#/${v}`); + } + + const loadAgents = useCallback(async () => { + try { + const payload = await apiFetch("/api/v1/agents?limit=100").then(r => r.json()); + const summaries: AgentSummary[] = payload.items || []; + const details = await Promise.all(summaries.map(agent => ( + apiFetch(`/api/v1/agents/${encodeURIComponent(agent.metadata.id)}`) + .then(r => r.ok ? r.json() : null) + .catch(() => null) + ))); + const items = summaries.map((agent, index) => ({ + ...agent, + builds: details[index]?.builds || [], + })); + setAgents(items); + setCurrentAgentId(prev => ( + items.some(agent => agent.metadata.id === prev) + ? prev + : items[0]?.metadata.id || "" + )); + } catch { + // 保留上一次成功加载的数据,刷新按钮可重新触发同步。 + } finally { + setAgentsLoaded(true); + } + }, []); + + useEffect(() => { loadAgents(); }, [loadAgents, refreshTick]); + + useEffect(() => { + apiFetch("/api/v1/system/bootstrap").then(r => r.json()).then(d => { + setWorkspace(d.workspace || null); + setRuntimeReady(Boolean(d.workspace)); + }).catch(() => setRuntimeReady(false)); + }, [refreshTick]); + + const currentAgent = agents.find(a => a.metadata.id === currentAgentId); + const runtimeType = (currentAgent as any)?.spec?.runtime?.type + || currentAgent?.metadata.labels?.["agentkit.ksyun.com/framework"] + || ""; + const runtimeState = runtimeReady ? "Ready" : "Connecting"; + + function switchAgent(id: string) { + if (!id) return; + setCurrentAgentId(id); + if (view === "conversations") setChatMounted(true); + } + + function enterChat(agentId?: string) { + const id = agentId || currentAgentId || agents[0]?.metadata.id || ""; + if (!id) { openCreate(); return; } + setCurrentAgentId(id); + setChatMounted(true); + setView("conversations"); + } + + function openDetail(agentId: string) { + setEditingAgentId(""); + setDetailAgentId(agentId); + setCurrentAgentId(agentId); + setView("agent-detail"); + } + + function openCreate() { + setEditingAgentId(""); + setView("create"); + } + + function openEdit(agentId: string) { + setEditingAgentId(agentId); + setCurrentAgentId(agentId); + setView("create"); + } + + function openResources(kind: ResourceKind) { + setResourceKind(kind); + setView("resources"); + } + + const breadcrumbParent = view === "create" || view === "agent-detail" ? "Agent" : null; + const breadcrumbTitle = VIEW_TITLE[view]; + + const workspaceName = workspace?.name || "Workspace"; + const workspacePath = workspace?.path || (runtimeReady ? "本地工作区" : "正在连接本地工作区"); + const focusedView = view === "create" + || view === "conversations" + || view === "observability"; + const railCanExpand = viewportMode !== "compact"; + const railExpanded = railCanExpand && (railExpandedPreference ?? false); + + function toggleRail() { + if (!railCanExpand) return; + const next = !railExpanded; + setRailExpandedPreference(next); + writeNavigationRailPreference(next); + } + + function navigateFromRail(nextView: NavigationView, kind?: ResourceKind) { + if (nextView === "conversations") enterChat(); + else if (nextView === "resources") openResources(kind || "model"); + else setView(nextView); + } + + return ( + <> + 跳到主要内容 +
+ setSettingsOpen(true)} + /> + +
+
+ {railCanExpand && ( + + )} + {breadcrumbParent && ( +
+ {breadcrumbParent} + + {breadcrumbTitle} +
+ )} +
+
+
+ Agent + ({ value: agent.metadata.id, label: agent.metadata.name }))} + onValueChange={switchAgent} + /> +
+
+ 目标 + undefined} + /> +
+ {runtimeType ? `${runtimeType} RuntimeAdapter` : "Runtime 未选择"} +
+ + + {view === "conversations" && chatMounted && currentAgentId && ( + + )} +
+ +
+ {/* 会话页常驻挂载(display 切换),来回切换不重建工作台 */} +
+
+ {chatMounted && currentAgentId && ( + + )} + {chatMounted && !currentAgentId && ( +
+ +

{agentsLoaded ? "先创建 Agent 才能开始会话" : "正在载入 Agent"}

+

{agentsLoaded ? "会话会使用当前 Agent 的模型、工具与运行时配置。" : "正在同步本地工作区…"}

+ {agentsLoaded && } +
+ )} +
+ {runPanelOpen && chatMounted && currentAgentId && ( + setRunPanelOpen(false)} onOpenTrace={() => setView("observability")} /> + )} +
+ +
+ {view === "agents" && ( + setView("builds")} + onChanged={loadAgents} + /> + )} + {view === "create" && ( + editingAgentId ? openDetail(editingAgentId) : setView("agents")} + onCreated={(id, openChat) => { + setEditingAgentId(""); + loadAgents(); + if (id && openChat) enterChat(id); + else if (id) openDetail(id); + else setView("agents"); + }} + /> + )} + {view === "agent-detail" && detailAgentId && ( + setView("agents")} + onChat={enterChat} + onBuild={() => setView("builds")} + onEdit={openEdit} + onChanged={loadAgents} + /> + )} + {view === "resources" && } + {view === "builds" && } + {view === "deployments" && } + {view === "observability" && ( + + )} + {view === "runtime-resources" && } + {view === "orchestration" && } +
+
+
+ + {settingsOpen && ( + setSettingsOpen(false)} + /> + )} + +
+ + ); +} diff --git a/ksadk/studio/react-ui/src/api.test.mjs b/ksadk/studio/react-ui/src/api.test.mjs new file mode 100644 index 00000000..a94da279 --- /dev/null +++ b/ksadk/studio/react-ui/src/api.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { transformWithOxc } from "vite"; + +const apiSource = await readFile(new URL("./api.ts", import.meta.url), "utf8"); + +function jsonResponse(payload, status = 200) { + return new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function loadApi({ hash, fetch }) { + const replacedUrls = []; + globalThis.window = { + fetch, + location: { + hash, + href: `http://127.0.0.1:5175/${hash}`, + origin: "http://127.0.0.1:5175", + pathname: "/", + search: "", + }, + history: { + replaceState(_state, _title, url) { + replacedUrls.push(url); + }, + }, + }; + + const transformed = await transformWithOxc(apiSource, "api.ts", { lang: "ts" }); + const uniqueSource = `${transformed.code}\n// test-instance-${Math.random()}`; + const moduleUrl = `data:text/javascript;base64,${Buffer.from(uniqueSource).toString("base64")}`; + return { api: await import(moduleUrl), replacedUrls }; +} + +test("session fragment is exchanged before API writes and removed from the URL", async () => { + const requests = []; + const { api, replacedUrls } = await loadApi({ + hash: "#session=cli-session-token", + fetch: async (input, init = {}) => { + requests.push({ input: String(input), init }); + if (String(input).endsWith("/api/v1/system/session")) { + return jsonResponse({ csrfToken: "csrf-from-fragment" }); + } + return jsonResponse({ error: { code: "NOT_FOUND" } }, 404); + }, + }); + + await api.initializeStudioSession(); + await api.apiFetch("/api/v1/write-probe", { method: "POST" }); + + assert.equal(requests.length, 2); + assert.equal( + JSON.parse(requests[0].init.body).token, + "cli-session-token", + ); + assert.equal( + new Headers(requests[1].init.headers).get("X-CSRF-Token"), + "csrf-from-fragment", + ); + assert.equal(requests[1].init.credentials, "same-origin"); + assert.deepEqual(replacedUrls, ["/"]); +}); + +test("an existing session cookie recovers its CSRF token from bootstrap", async () => { + const requests = []; + const { api } = await loadApi({ + hash: "#/agents", + fetch: async (input, init = {}) => { + requests.push({ input: String(input), init }); + if (String(input).endsWith("/api/v1/system/bootstrap")) { + return jsonResponse({ csrfToken: "csrf-from-cookie" }); + } + return jsonResponse({ error: { code: "NOT_FOUND" } }, 404); + }, + }); + + await api.initializeStudioSession(); + await api.apiFetch("/api/v1/write-probe", { method: "DELETE" }); + + assert.equal( + new Headers(requests[1].init.headers).get("X-CSRF-Token"), + "csrf-from-cookie", + ); +}); + +test("OpenAI-compatible response writes receive the same Studio CSRF protection", async () => { + const requests = []; + const { api } = await loadApi({ + hash: "#/conversations", + fetch: async (input, init = {}) => { + requests.push({ input: String(input), init }); + if (String(input).endsWith("/api/v1/system/bootstrap")) { + return jsonResponse({ csrfToken: "csrf-for-responses" }); + } + return jsonResponse({ status: "accepted" }, 202); + }, + }); + + await api.initializeStudioSession(); + await api.apiFetch("/v1/responses/resp-1:pause", { method: "POST" }); + + assert.equal( + new Headers(requests[1].init.headers).get("X-CSRF-Token"), + "csrf-for-responses", + ); + assert.equal(requests[1].init.credentials, "same-origin"); +}); + +test("a stale CSRF token is refreshed once before retrying a local write", async () => { + const requests = []; + let bootstrapCount = 0; + const { api } = await loadApi({ + hash: "#/agents", + fetch: async (input, init = {}) => { + const request = { input: String(input), init }; + requests.push(request); + if (request.input.endsWith("/api/v1/system/bootstrap")) { + bootstrapCount += 1; + return jsonResponse({ + csrfToken: bootstrapCount === 1 ? "stale-csrf" : "fresh-csrf", + }); + } + if (new Headers(init.headers).get("X-CSRF-Token") === "stale-csrf") { + return jsonResponse( + { error: { code: "CSRF_TOKEN_INVALID", message: "stale" } }, + 403, + ); + } + return jsonResponse({ saved: true }); + }, + }); + + await api.initializeStudioSession(); + const response = await api.apiFetch("/api/v1/system/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sandbox: "read-only" }), + }); + + assert.equal(response.status, 200); + assert.equal(requests.length, 4); + assert.equal( + new Headers(requests[3].init.headers).get("X-CSRF-Token"), + "fresh-csrf", + ); +}); diff --git a/ksadk/studio/react-ui/src/api.ts b/ksadk/studio/react-ui/src/api.ts new file mode 100644 index 00000000..607bf7c3 --- /dev/null +++ b/ksadk/studio/react-ui/src/api.ts @@ -0,0 +1,103 @@ +let csrfToken = ""; +const nativeFetch = window.fetch.bind(window); + +function isProtectedStudioPath(pathname: string): boolean { + return pathname.startsWith("/api/v1/") + || pathname === "/v1/responses" + || pathname.startsWith("/v1/responses/"); +} + +export async function apiFetch( + input: RequestInfo | URL, + init: RequestInit = {}, +): Promise { + const method = String( + init.method || (input instanceof Request ? input.method : "GET"), + ).toUpperCase(); + const url = new URL( + input instanceof Request ? input.url : String(input), + window.location.href, + ); + const headers = new Headers( + init.headers || (input instanceof Request ? input.headers : undefined), + ); + + if ( + csrfToken + && url.origin === window.location.origin + && isProtectedStudioPath(url.pathname) + && !["GET", "HEAD", "OPTIONS"].includes(method) + ) { + headers.set("X-CSRF-Token", csrfToken); + } + + const retryInput = input instanceof Request ? input.clone() : input; + const requestInit = { + ...init, + headers, + credentials: init.credentials || "same-origin", + }; + const response = await nativeFetch(input, requestInit); + if ( + response.status !== 403 + || ["GET", "HEAD", "OPTIONS"].includes(method) + || url.origin !== window.location.origin + || !isProtectedStudioPath(url.pathname) + ) { + return response; + } + + let errorCode = ""; + try { + errorCode = (await response.clone().json())?.error?.code || ""; + } catch { + return response; + } + if (errorCode !== "CSRF_TOKEN_INVALID") return response; + + const bootstrap = await nativeFetch("/api/v1/system/bootstrap", { + credentials: "same-origin", + }); + if (!bootstrap.ok) return response; + const payload = await bootstrap.json(); + csrfToken = payload.csrfToken || ""; + if (!csrfToken) return response; + + headers.set("X-CSRF-Token", csrfToken); + return nativeFetch(retryInput, { ...requestInit, headers }); +} + +export async function initializeStudioSession(): Promise { + const match = window.location.hash.match(/(?:^#|&)session=([^&]+)/); + if (match) { + const response = await nativeFetch("/api/v1/system/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: decodeURIComponent(match[1]) }), + credentials: "same-origin", + }); + if (!response.ok) { + throw new Error("本地 Studio 会话已失效,请重新启动服务。"); + } + + const payload = await response.json(); + csrfToken = payload.csrfToken || ""; + window.history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}`, + ); + return; + } + + const bootstrap = await nativeFetch("/api/v1/system/bootstrap", { + credentials: "same-origin", + }); + if (!bootstrap.ok) return; + const payload = await bootstrap.json(); + csrfToken = payload.csrfToken || ""; +} + +export function currentCsrfToken(): string { + return csrfToken; +} diff --git a/ksadk/studio/react-ui/src/approvalModes.test.mjs b/ksadk/studio/react-ui/src/approvalModes.test.mjs new file mode 100644 index 00000000..d057ac09 --- /dev/null +++ b/ksadk/studio/react-ui/src/approvalModes.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { transformWithOxc } from "vite"; + +async function loadApprovalModes() { + const source = await readFile(new URL("./approvalModes.ts", import.meta.url), "utf8"); + const transformed = await transformWithOxc(source, "approvalModes.ts", { lang: "ts" }); + const moduleUrl = `data:text/javascript;base64,${Buffer.from(transformed.code).toString("base64")}`; + return import(moduleUrl); +} + +test("exposes exactly three approval levels with risk as the safe default", async () => { + const approval = await loadApprovalModes(); + + assert.deepEqual(approval.APPROVAL_MODES.map(item => item.value), ["ask", "risk", "full"]); + assert.deepEqual(approval.APPROVAL_MODES.map(item => item.label), ["请求批准", "帮我批准", "完全访问权限"]); + assert.equal(approval.normalizeApprovalMode("ask"), "ask"); + assert.equal(approval.normalizeApprovalMode("full"), "full"); + assert.equal(approval.normalizeApprovalMode("unknown"), "risk"); +}); + +test("keeps approval preference scoped to one agent", async () => { + const approval = await loadApprovalModes(); + + assert.equal(approval.approvalModeStorageKey("research-agent"), "agentkit-studio:approval:research-agent"); +}); diff --git a/ksadk/studio/react-ui/src/approvalModes.ts b/ksadk/studio/react-ui/src/approvalModes.ts new file mode 100644 index 00000000..115aa23c --- /dev/null +++ b/ksadk/studio/react-ui/src/approvalModes.ts @@ -0,0 +1,41 @@ +export type ApprovalMode = "ask" | "risk" | "full"; + +export interface ApprovalModeOption { + value: ApprovalMode; + label: string; + compactLabel: string; + description: string; +} + +export const APPROVAL_MODES: readonly ApprovalModeOption[] = [ + { + value: "ask", + label: "请求批准", + compactLabel: "请求批准", + description: "文件修改与外部写入会逐次请求确认", + }, + { + value: "risk", + label: "帮我批准", + compactLabel: "帮我批准", + description: "仅在检测到风险操作时请求确认", + }, + { + value: "full", + label: "完全访问权限", + compactLabel: "完全访问", + description: "不受限制地访问互联网和工作区文件", + }, +] as const; + +export function normalizeApprovalMode(value: unknown): ApprovalMode { + return value === "ask" || value === "risk" || value === "full" ? value : "risk"; +} + +export function approvalModeStorageKey(agentId: string): string { + return `agentkit-studio:approval:${agentId}`; +} + +export function approvalModeOption(value: ApprovalMode): ApprovalModeOption { + return APPROVAL_MODES.find(item => item.value === value) || APPROVAL_MODES[1]; +} diff --git a/ksadk/studio/react-ui/src/chatProtocol.test.mjs b/ksadk/studio/react-ui/src/chatProtocol.test.mjs new file mode 100644 index 00000000..03303aed --- /dev/null +++ b/ksadk/studio/react-ui/src/chatProtocol.test.mjs @@ -0,0 +1,241 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { transformWithOxc } from "vite"; + +async function loadChatProtocol() { + let source; + try { + source = await readFile(new URL("./chatProtocol.ts", import.meta.url), "utf8"); + } catch (error) { + assert.fail(`chatProtocol.ts must own the Responses stream: ${error.message}`); + } + const transformed = await transformWithOxc(source, "chatProtocol.ts", { lang: "ts" }); + const moduleUrl = `data:text/javascript;base64,${Buffer.from(transformed.code).toString("base64")}`; + return import(moduleUrl); +} + +test("parses fragmented Responses SSE and accumulates reasoning plus output", async () => { + const chat = await loadChatProtocol(); + const events = []; + const parser = chat.createResponseSseParser(event => events.push(event)); + parser.push("event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n"); + parser.push("\nevent: response.reasoning_summary_text.delta\ndata: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"先分析\"}\n\n"); + parser.push(": keep-alive\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"答案\"}\n\n"); + parser.push("event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"call_1\",\"type\":\"shell_call\",\"action\":{\"commands\":[\"rg TODO\"]},\"status\":\"in_progress\"}}\n\n"); + parser.push("event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"call_1\",\"type\":\"shell_call\",\"action\":{\"commands\":[\"rg TODO\"]},\"status\":\"completed\",\"exit_code\":0}}\n\n"); + parser.push("event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"metadata\":{\"runtime_run_id\":\"run_1\"}}}\n\n"); + parser.finish(); + + const state = events.reduce(chat.reduceChatStreamEvent, chat.createChatStreamState("resp_local", "ses_1")); + assert.equal(state.responseId, "resp_1"); + assert.equal(state.reasoning, "先分析"); + assert.equal(state.output, "答案"); + assert.equal(state.runId, "run_1"); + assert.equal(state.status, "completed"); + assert.deepEqual(state.activities.map(item => [item.kind, item.status, item.title]), [ + ["command", "completed", "rg TODO"], + ]); +}); + +test("groups persisted runs into newest-first sessions for one agent", async () => { + const chat = await loadChatProtocol(); + const sessions = chat.groupRunsBySession([ + { id: "run-a1", agentId: "agent-a", sessionId: "ses-old", input: "旧问题", startedAt: "2026-08-09T08:00:00Z" }, + { id: "run-b", agentId: "agent-b", sessionId: "ses-other", input: "忽略", startedAt: "2026-08-10T10:00:00Z" }, + { id: "run-a2", agentId: "agent-a", sessionId: "ses-new", input: "新问题", startedAt: "2026-08-10T09:00:00Z" }, + { id: "run-a3", agentId: "agent-a", sessionId: "ses-new", input: "追问", startedAt: "2026-08-10T09:05:00Z" }, + ], "agent-a"); + + assert.deepEqual(sessions.map(item => item.id), ["ses-new", "ses-old"]); + assert.equal(sessions[0].title, "新问题"); + assert.deepEqual(sessions[0].runs.map(item => item.id), ["run-a2", "run-a3"]); +}); + +test("recovers the latest persisted running run after a page refresh", async () => { + const chat = await loadChatProtocol(); + assert.equal(chat.latestRunningRun([ + { id: "run-old", status: "RUNNING", startedAt: "2026-08-10T09:00:00Z" }, + { id: "run-done", status: "COMPLETED", startedAt: "2026-08-10T09:05:00Z" }, + { id: "run-live", status: "RUNNING", startedAt: "2026-08-10T09:10:00Z" }, + ])?.id, "run-live"); + assert.equal(chat.latestRunningRun([ + { id: "run-done", status: "COMPLETED" }, + ]), undefined); +}); + +test("treats paused and input-required runs as recoverable active work", async () => { + const chat = await loadChatProtocol(); + assert.equal(chat.latestActiveRun([ + { id: "run-running", status: "RUNNING", startedAt: "2026-08-10T09:00:00Z" }, + { id: "run-paused", status: "PAUSED", startedAt: "2026-08-10T09:10:00Z" }, + { id: "run-waiting", status: "WAITING_INPUT", startedAt: "2026-08-10T09:20:00Z" }, + ])?.id, "run-waiting"); + + const [pausedSession] = chat.groupRunsBySession([ + { + id: "run-paused", + agentId: "agent-a", + sessionId: "ses-paused", + input: "暂停测试", + status: "PAUSED", + startedAt: "2026-08-10T09:10:00Z", + }, + ], "agent-a"); + assert.equal(pausedSession.activeStatus, "PAUSED"); + assert.deepEqual( + chat.persistedRunsForDisplay(pausedSession.runs, "run-paused").map(run => run.id), + [], + ); +}); + +test("reduces streamed A2UI operations and interaction state without React coupling", async () => { + const chat = await loadChatProtocol(); + let state = chat.createChatStreamState("resp-a2ui", "ses-a2ui"); + state = chat.reduceChatStreamEvent(state, { + type: "a2ui.surface.begin", + runId: "run-a2ui", + surfaceId: "surface-1", + a2uiOperations: [ + { version: "v0.9", createSurface: { surfaceId: "surface-1", catalogId: "catalog-1" } }, + { version: "v0.9", updateComponents: { surfaceId: "surface-1", components: [ + { id: "root", component: "Card", title: "需要确认", children: ["approval"] }, + { id: "approval", component: "ApprovalBar", approve_label: "批准", deny_label: "拒绝" }, + ] } }, + ], + }); + state = chat.reduceChatStreamEvent(state, { + type: "a2ui.interaction", + runId: "run-a2ui", + surfaceId: "surface-1", + interactionId: "approval-1", + kind: "approval", + inputSchema: { type: "object" }, + }); + + assert.equal(state.runId, "run-a2ui"); + assert.equal(state.status, "waiting_input"); + assert.equal(state.surfaces[0].id, "surface-1"); + assert.equal(state.surfaces[0].components.approval.component, "ApprovalBar"); + assert.equal(state.surfaces[0].interaction.id, "approval-1"); + + state = chat.reduceChatStreamEvent(state, { + type: "a2ui.action", + surfaceId: "surface-1", + interactionId: "approval-1", + name: "approve", + }); + assert.equal(state.status, "streaming"); + assert.equal(state.surfaces[0].interaction.status, "resolved"); +}); + +test("projects persisted A2UI operations for refresh replay", async () => { + const chat = await loadChatProtocol(); + const surfaces = chat.projectA2UISurfaces([ + { id: 1, type: "a2ui.surface.begin", data: { + surfaceId: "surface-1", + a2uiOperations: [ + { version: "v0.9", createSurface: { surfaceId: "surface-1", catalogId: "catalog-1" } }, + { version: "v0.9", updateDataModel: { surfaceId: "surface-1", path: "/", value: { selected: ["a"] } } }, + ], + } }, + { id: 2, type: "a2ui.interaction", data: { + surfaceId: "surface-1", interactionId: "interaction-1", kind: "multi_select", + } }, + ]); + assert.deepEqual(surfaces[0].dataModel, { selected: ["a"] }); + assert.equal(surfaces[0].interaction.kind, "multi_select"); +}); + +test("derives an honest context ring from the latest reported input usage", async () => { + const chat = await loadChatProtocol(); + assert.equal(typeof chat.contextUsageState, "function"); + assert.deepEqual(chat.contextUsageState(8192, 32768), { + known: true, + usedTokens: 8192, + limitTokens: 32768, + percent: 25, + }); + assert.deepEqual(chat.contextUsageState(undefined, 32768), { + known: false, + usedTokens: 0, + limitTokens: 32768, + percent: 0, + }); + assert.equal(chat.contextUsageState(40000, 32768).percent, 100); + assert.equal(chat.latestReportedInputTokens([ + { usage: { inputTokens: 2048, reported: true } }, + { usage: { inputTokens: 0, reported: false } }, + ]), 2048); + assert.equal(chat.latestReportedInputTokens([ + { usage: { inputTokens: 0, reported: false } }, + ]), undefined); + assert.deepEqual(chat.contextUsageTooltip(chat.contextUsageState(4481, 32000)), { + title: "上下文窗口", + value: "14% 已用", + detail: "已用 4,481 tokens,共 32,000", + }); + assert.deepEqual(chat.contextUsageTooltip(chat.contextUsageState(undefined, 32000)), { + title: "上下文窗口", + value: "用量未上报", + detail: "上限 32,000 tokens", + }); +}); + +test("projects command, tool, and approval events into readable activity cards", async () => { + const chat = await loadChatProtocol(); + const cards = chat.projectRunActivities([ + { id: 1, type: "thinking.delta", data: { text: "检查上下文" } }, + { id: 2, type: "command.started", data: { command: "rg TODO", callId: "c1" } }, + { id: 3, type: "command.completed", data: { callId: "c1", exitCode: 0 } }, + { id: 4, type: "tool.started", data: { name: "search", callId: "t1" } }, + { id: 5, type: "approval.requested", data: { kind: "workspace-write" } }, + { id: 6, type: "message.delta", data: { text: "恢复后的" } }, + { id: 7, type: "message.delta", data: { text: "流式正文" } }, + { id: 8, type: "tool.completed", data: { callId: "t1", error: "upstream rejected" } }, + ]); + + assert.equal(cards.reasoning, "检查上下文"); + assert.deepEqual(cards.activities.map(item => [item.kind, item.status]), [ + ["command", "completed"], + ["tool", "failed"], + ["approval", "waiting"], + ]); + assert.equal(cards.activities[0].title, "rg TODO"); + assert.equal(cards.activities[1].title, "search"); + assert.equal(cards.activities[1].detail, "upstream rejected"); + assert.equal(cards.output, "恢复后的流式正文"); +}); + +test("compacts persisted run events into a restrained inspector timeline", async () => { + const chat = await loadChatProtocol(); + assert.equal(typeof chat.projectRunInspectorTimeline, "function"); + + const timeline = chat.projectRunInspectorTimeline([ + { id: 1, type: "run.created", data: { model: "glm-5.2" }, createdAt: "2026-08-10T10:00:00Z" }, + { id: 2, type: "run.started", data: { runtimeType: "codex" }, createdAt: "2026-08-10T10:00:01Z" }, + { id: 3, type: "thinking.delta", data: { text: "检查" }, createdAt: "2026-08-10T10:00:02Z" }, + { id: 4, type: "thinking.delta", data: { text: "上下文" }, createdAt: "2026-08-10T10:00:03Z" }, + { id: 5, type: "tool.started", data: { name: "search", callId: "tool-1" }, createdAt: "2026-08-10T10:00:04Z" }, + { id: 6, type: "tool.completed", data: { callId: "tool-1", result: "2 results" }, createdAt: "2026-08-10T10:00:05Z" }, + { id: 7, type: "message.delta", data: { text: "连接" }, createdAt: "2026-08-10T10:00:06Z" }, + { id: 8, type: "message.completed", data: { text: "连接成功" }, createdAt: "2026-08-10T10:00:07Z" }, + { id: 9, type: "usage.reported", data: { input_tokens: 4481, output_tokens: 6, total_tokens: 4487 }, createdAt: "2026-08-10T10:00:08Z" }, + { id: 10, type: "run.completed", data: { duration_ms: 1537 }, createdAt: "2026-08-10T10:00:09Z" }, + ]); + + assert.deepEqual(timeline.map(item => [item.kind, item.status]), [ + ["run", "running"], + ["thinking", "completed"], + ["tool", "completed"], + ["message", "completed"], + ["usage", "completed"], + ["run", "completed"], + ]); + assert.equal(timeline[1].detail, "检查上下文"); + assert.equal(timeline[2].title, "search"); + assert.equal(timeline[2].detail, "2 results"); + assert.equal(timeline[3].detail, "连接成功"); + assert.equal(timeline[4].summary, "4,487 tokens"); + assert.equal(timeline.some(item => item.title === "Run 创建"), false); +}); diff --git a/ksadk/studio/react-ui/src/chatProtocol.ts b/ksadk/studio/react-ui/src/chatProtocol.ts new file mode 100644 index 00000000..36a766c0 --- /dev/null +++ b/ksadk/studio/react-ui/src/chatProtocol.ts @@ -0,0 +1,744 @@ +export interface ChatRun { + id: string; + agentId: string; + sessionId: string; + status?: string; + input: string; + output?: string; + model?: string; + collaborationMode?: string; + goalObjective?: string; + traceId?: string; + startedAt?: string; + completedAt?: string; + durationMs?: number | null; + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + reasoningOutputTokens?: number; + reported?: boolean; + }; + error?: { code?: string; message?: string } | null; +} + +export interface ChatSession { + id: string; + title: string; + updatedAt: string; + running: boolean; + activeStatus?: string; + runs: ChatRun[]; +} + +export interface ResponseStreamEvent { + type: string; + [key: string]: unknown; +} + +export interface ChatStreamState { + localId: string; + responseId: string; + sessionId: string; + runId: string; + reasoning: string; + output: string; + status: "streaming" | "paused" | "waiting_input" | "completed" | "failed" | "cancelled"; + error: string; + activities: RunActivity[]; + surfaces: A2UISurface[]; + usage?: Record; + collaborationMode?: string; + goalObjective?: string; + startedAt?: string; +} + +export interface A2UIInteraction { + id: string; + kind: string; + status: "pending" | "resolved" | "expired"; + inputSchema: Record; +} + +export interface A2UIComponent { + id: string; + component: string; + [key: string]: unknown; +} + +export interface A2UISurface { + id: string; + catalogId: string; + components: Record; + roots: string[]; + dataModel: Record; + interaction?: A2UIInteraction; +} + +export interface RunEvent { + id: number; + type: string; + data?: Record; + createdAt?: string; +} + +export interface RunActivity { + id: string; + kind: "command" | "tool" | "approval"; + title: string; + status: "running" | "completed" | "failed" | "waiting"; + detail: string; + data: Record; +} + +export interface RunActivityProjection { + reasoning: string; + output: string; + activities: RunActivity[]; +} + +export interface RunInspectorTimelineItem { + id: string; + kind: "run" | "thinking" | "message" | "command" | "tool" | "approval" | "usage"; + title: string; + summary: string; + detail: string; + status: RunActivity["status"]; + createdAt?: string; + data: Record; +} + +export interface ContextUsageState { + known: boolean; + usedTokens: number; + limitTokens: number; + percent: number; +} + +export interface ContextUsageTooltip { + title: string; + value: string; + detail: string; +} + +export function contextUsageState( + inputTokens: number | undefined, + contextWindowTokens: number | undefined, +): ContextUsageState { + const limitTokens = Math.max(0, Number(contextWindowTokens) || 0); + const known = Number.isFinite(inputTokens) && Number(inputTokens) >= 0 && limitTokens > 0; + const usedTokens = known ? Math.max(0, Number(inputTokens)) : 0; + const percent = known + ? Math.max(0, Math.min(100, Math.round((usedTokens / limitTokens) * 100))) + : 0; + return { known, usedTokens, limitTokens, percent }; +} + +export function contextUsageTooltip(state: ContextUsageState): ContextUsageTooltip { + return { + title: "上下文窗口", + value: state.known ? `${state.percent}% 已用` : "用量未上报", + detail: state.known + ? `已用 ${state.usedTokens.toLocaleString("en-US")} tokens,共 ${state.limitTokens.toLocaleString("en-US")}` + : state.limitTokens > 0 + ? `上限 ${state.limitTokens.toLocaleString("en-US")} tokens` + : "当前模型未提供上下文上限", + }; +} + +export function latestReportedInputTokens( + runs: Array>, +): number | undefined { + return [...runs] + .reverse() + .find(run => run.usage?.reported === true && Number.isFinite(run.usage.inputTokens)) + ?.usage?.inputTokens; +} + +export function latestRunningRun>( + runs: T[], +): T | undefined { + return [...runs] + .filter(run => run.status === "RUNNING") + .sort((left, right) => Date.parse(left.startedAt || "1970-01-01") - Date.parse(right.startedAt || "1970-01-01")) + .at(-1); +} + +const ACTIVE_RUN_STATUSES = new Set(["RUNNING", "PAUSED", "WAITING_INPUT"]); + +export function latestActiveRun>( + runs: T[], +): T | undefined { + return [...runs] + .filter(run => ACTIVE_RUN_STATUSES.has(String(run.status || ""))) + .sort((left, right) => Date.parse(left.startedAt || "1970-01-01") - Date.parse(right.startedAt || "1970-01-01")) + .at(-1); +} + +export function persistedRunsForDisplay>( + runs: T[], + optimisticRunId?: string, +): T[] { + return optimisticRunId ? runs.filter(run => run.id !== optimisticRunId) : runs; +} + +function timestamp(run: ChatRun): number { + const value = run.completedAt || run.startedAt || ""; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function groupRunsBySession(runs: ChatRun[], agentId: string): ChatSession[] { + const grouped = new Map(); + for (const run of runs) { + if (run.agentId !== agentId || !run.sessionId) continue; + const current = grouped.get(run.sessionId) || []; + current.push(run); + grouped.set(run.sessionId, current); + } + + return [...grouped.entries()] + .map(([id, items]) => { + const ordered = [...items].sort((left, right) => timestamp(left) - timestamp(right)); + const first = ordered[0]; + const last = ordered.at(-1)!; + const active = [...ordered].reverse().find(run => ACTIVE_RUN_STATUSES.has(String(run.status || ""))); + return { + id, + title: first?.input?.trim() || "新会话", + updatedAt: last.completedAt || last.startedAt || "", + running: Boolean(active), + activeStatus: active?.status, + runs: ordered, + }; + }) + .sort((left, right) => Date.parse(right.updatedAt || "1970-01-01") - Date.parse(left.updatedAt || "1970-01-01")); +} + +export function createChatStreamState(localId: string, sessionId: string): ChatStreamState { + return { + localId, + responseId: localId, + sessionId, + runId: "", + reasoning: "", + output: "", + status: "streaming", + error: "", + activities: [], + surfaces: [], + }; +} + +function recordOf(value: unknown): Record { + return value && typeof value === "object" ? value as Record : {}; +} + +export function reduceChatStreamEvent( + state: ChatStreamState, + event: ResponseStreamEvent, +): ChatStreamState { + const type = String(event.type || ""); + if (type === "response.created" || type === "response.in_progress") { + const response = recordOf(event.response); + return { ...state, responseId: String(response.id || state.responseId) }; + } + if (type === "response.reasoning_summary_text.delta") { + return { ...state, reasoning: state.reasoning + String(event.delta || "") }; + } + if (type === "response.output_text.delta") { + return { ...state, output: state.output + String(event.delta || "") }; + } + if (type === "response.output_item.added" || type === "response.output_item.done") { + const activity = responseItemActivity(recordOf(event.item), type.endsWith(".done")); + if (!activity) return state; + const index = state.activities.findIndex(item => item.id === activity.id); + const activities = [...state.activities]; + if (index < 0) activities.push(activity); + else activities[index] = { ...activities[index], ...activity }; + return { ...state, activities }; + } + if (type.startsWith("a2ui.")) { + return reduceA2UIEvent(state, event); + } + if (type === "response.paused") { + return { + ...state, + runId: String(event.runId || event.run_id || state.runId), + status: "paused", + }; + } + if (type === "response.resumed") { + return { + ...state, + runId: String(event.runId || event.run_id || state.runId), + status: "streaming", + }; + } + if (/^response\.(?:web_search_call|file_search_call|mcp_call)\./.test(type)) { + const id = String(event.item_id || event.itemId || event.call_id || event.callId || type.split(".")[1]); + const kind: RunActivity["kind"] = "tool"; + const title = type.includes("web_search") ? "网页搜索" + : type.includes("file_search") ? "文件搜索" + : "MCP 调用"; + const status: RunActivity["status"] = type.endsWith(".failed") ? "failed" + : type.endsWith(".completed") ? "completed" + : "running"; + const activity: RunActivity = { id: `tool:${id}`, kind, title, status, detail: "", data: recordOf(event) }; + const index = state.activities.findIndex(item => item.id === activity.id); + const activities = [...state.activities]; + if (index < 0) activities.push(activity); + else activities[index] = { ...activities[index], ...activity }; + return { ...state, activities }; + } + if (type === "response.completed") { + const response = recordOf(event.response); + const metadata = recordOf(response.metadata); + return { + ...state, + responseId: String(response.id || state.responseId), + runId: String(metadata.runtime_run_id || metadata.runtimeRunId || state.runId), + usage: recordOf(response.usage) as Record, + status: "completed", + }; + } + if (type === "response.cancelled" || type === "response.canceled") { + return { ...state, status: "cancelled" }; + } + if (type === "response.failed" || type === "error") { + const response = recordOf(event.response); + const error = recordOf(event.error || response.error); + return { + ...state, + status: "failed", + error: String(error.message || event.message || "Agent 运行失败"), + }; + } + return state; +} + +function emptySurface(id: string): A2UISurface { + return { + id, + catalogId: "", + components: {}, + roots: [], + dataModel: {}, + }; +} + +function operationList(event: ResponseStreamEvent): Record[] { + const raw = event.a2uiOperations ?? event.a2ui_operations ?? event.operations; + return Array.isArray(raw) ? raw.map(recordOf) : []; +} + +function applyA2UIOperations( + surfaces: Map, + operations: Record[], +): void { + for (const operation of operations) { + const create = recordOf(operation.createSurface); + if (Object.keys(create).length) { + const id = String(create.surfaceId || create.surface_id || ""); + if (!id) continue; + const current = surfaces.get(id) || emptySurface(id); + surfaces.set(id, { ...current, catalogId: String(create.catalogId || create.catalog_id || current.catalogId) }); + continue; + } + const update = recordOf(operation.updateComponents); + if (Object.keys(update).length) { + const id = String(update.surfaceId || update.surface_id || ""); + if (!id) continue; + const current = surfaces.get(id) || emptySurface(id); + const components = { ...current.components }; + const nextComponents = Array.isArray(update.components) ? update.components.map(recordOf) : []; + for (const raw of nextComponents) { + const componentId = String(raw.id || raw.componentId || raw.component_id || ""); + const componentType = String(raw.component || raw.type || ""); + if (componentId && componentType) components[componentId] = { ...raw, id: componentId, component: componentType }; + } + const referenced = new Set(); + for (const component of Object.values(components)) { + const children = Array.isArray(component.children) ? component.children : []; + for (const child of children) if (typeof child === "string") referenced.add(child); + if (typeof component.child === "string") referenced.add(component.child); + } + const roots = Object.keys(components).filter(componentId => !referenced.has(componentId)); + surfaces.set(id, { ...current, components, roots }); + continue; + } + const data = recordOf(operation.updateDataModel); + if (Object.keys(data).length) { + const id = String(data.surfaceId || data.surface_id || ""); + if (!id) continue; + const current = surfaces.get(id) || emptySurface(id); + const value = recordOf(data.value); + surfaces.set(id, { ...current, dataModel: String(data.path || "/") === "/" ? value : { ...current.dataModel, ...value } }); + continue; + } + const remove = recordOf(operation.deleteSurface); + if (Object.keys(remove).length) { + const id = String(remove.surfaceId || remove.surface_id || ""); + if (id) surfaces.delete(id); + } + } +} + +function reduceA2UIEvent(state: ChatStreamState, event: ResponseStreamEvent): ChatStreamState { + const surfaces = new Map(state.surfaces.map(surface => [surface.id, { + ...surface, + components: { ...surface.components }, + dataModel: { ...surface.dataModel }, + }])); + applyA2UIOperations(surfaces, operationList(event)); + const type = String(event.type || ""); + const surfaceId = String(event.surfaceId || event.surface_id || ""); + if (type === "a2ui.interaction" && surfaceId) { + const current = surfaces.get(surfaceId) || emptySurface(surfaceId); + surfaces.set(surfaceId, { + ...current, + interaction: { + id: String(event.interactionId || event.interaction_id || ""), + kind: String(event.kind || "form"), + status: "pending", + inputSchema: recordOf(event.inputSchema || event.input_schema), + }, + }); + } + if (type === "a2ui.action" && surfaceId) { + const current = surfaces.get(surfaceId); + if (current?.interaction) { + surfaces.set(surfaceId, { + ...current, + interaction: { ...current.interaction, status: "resolved" }, + }); + } + } + return { + ...state, + runId: String(event.runId || event.run_id || state.runId), + surfaces: [...surfaces.values()], + status: type === "a2ui.interaction" ? "waiting_input" + : type === "a2ui.action" ? "streaming" + : state.status, + }; +} + +export function projectA2UISurfaces(events: RunEvent[]): A2UISurface[] { + let state = createChatStreamState("persisted", "persisted"); + for (const event of events) { + if (!event.type.startsWith("a2ui.")) continue; + state = reduceA2UIEvent(state, { type: event.type, ...(event.data || {}) }); + } + return state.surfaces; +} + +export function createResponseSseParser(onEvent: (event: ResponseStreamEvent) => void) { + let buffer = ""; + + const flush = (final = false) => { + buffer = buffer.replaceAll("\r\n", "\n"); + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0) { + parseBlock(buffer.slice(0, boundary)); + buffer = buffer.slice(boundary + 2); + boundary = buffer.indexOf("\n\n"); + } + if (final && buffer.trim()) { + parseBlock(buffer); + buffer = ""; + } + }; + + const parseBlock = (block: string) => { + let eventName = "message"; + const data: string[] = []; + for (const line of block.split("\n")) { + if (!line || line.startsWith(":")) continue; + if (line.startsWith("event:")) eventName = line.slice(6).trim(); + if (line.startsWith("data:")) data.push(line.slice(5).trimStart()); + } + if (!data.length) return; + const raw = data.join("\n"); + if (raw === "[DONE]") return; + try { + const parsed = JSON.parse(raw) as ResponseStreamEvent; + onEvent({ ...parsed, type: String(parsed.type || eventName) }); + } catch { + onEvent({ type: eventName, message: raw }); + } + }; + + return { + push(chunk: string) { + buffer += chunk; + flush(); + }, + finish() { + flush(true); + }, + }; +} + +function callKey(data: Record, fallback: string): string { + return String(data.callId || data.call_id || data.toolCallId || data.tool_call_id || fallback); +} + +function eventTitle(kind: RunActivity["kind"], data: Record): string { + if (kind === "command") return String(data.command || data.name || "执行命令"); + if (kind === "tool") return String(data.name || data.tool || "调用工具"); + return String(data.kind || data.action || "等待批准"); +} + +function eventDetail(data: Record): string { + const selected = data.output ?? data.result ?? data.message ?? data.error ?? ""; + if (typeof selected === "string") return selected; + if (selected && typeof selected === "object") return JSON.stringify(selected, null, 2); + return selected === "" ? "" : String(selected); +} + +function responseItemActivity(item: Record, done: boolean): RunActivity | null { + const itemType = String(item.type || ""); + if (!["function_call", "mcp_call", "shell_call", "local_shell_call", "file_search_call", "web_search_call", "approval_request"].includes(itemType)) { + return null; + } + const id = String(item.call_id || item.callId || item.id || "tool"); + const commandLike = itemType === "shell_call" || itemType === "local_shell_call"; + const approvalLike = itemType === "approval_request"; + const kind: RunActivity["kind"] = approvalLike ? "approval" : commandLike ? "command" : "tool"; + const action = recordOf(item.action); + const commands = Array.isArray(action.commands) + ? action.commands.filter(value => typeof value === "string").join(" && ") + : ""; + const title = approvalLike + ? String(action.title || action.kind || "等待批准") + : commandLike + ? commands || String(item.name || "执行命令") + : itemType === "web_search_call" ? "网页搜索" + : itemType === "file_search_call" ? "文件搜索" + : String(item.name || item.server_label || "调用工具"); + const rawStatus = String(item.status || ""); + const failed = rawStatus === "failed" || rawStatus === "error" + || Number(item.exit_code ?? item.exitCode ?? 0) !== 0; + const status: RunActivity["status"] = failed ? "failed" : approvalLike && !done ? "waiting" : done ? "completed" : "running"; + return { + id: `${kind}:${id}`, + kind, + title, + status, + detail: eventDetail(item), + data: item, + }; +} + +export function projectRunActivities(events: RunEvent[]): RunActivityProjection { + let reasoning = ""; + let output = ""; + const activities: RunActivity[] = []; + const byKey = new Map(); + + for (const event of events) { + const data = event.data || {}; + if (event.type === "thinking.delta" || event.type === "thinking.completed") { + const text = String(data.text || data.delta || ""); + reasoning = event.type === "thinking.completed" && text ? text : reasoning + text; + continue; + } + if (event.type === "message.delta" || event.type === "message.completed") { + const text = String(data.text || data.delta || ""); + output = event.type === "message.completed" && text ? text : output + text; + continue; + } + + let kind: RunActivity["kind"] | null = null; + if (event.type.startsWith("command.")) kind = "command"; + else if (event.type.startsWith("tool.")) kind = "tool"; + else if (event.type === "approval.requested") kind = "approval"; + if (!kind) continue; + + const key = callKey(data, `${kind}-${event.id}`); + const existingIndex = byKey.get(`${kind}:${key}`); + const completed = event.type.endsWith(".completed"); + const failed = event.type.endsWith(".failed") || Boolean(data.error) + || Number(data.exitCode ?? data.exit_code ?? 0) !== 0; + const status: RunActivity["status"] = kind === "approval" + ? "waiting" + : failed + ? "failed" + : completed + ? "completed" + : "running"; + const next: RunActivity = { + id: `${kind}:${key}`, + kind, + title: eventTitle(kind, data), + status, + detail: eventDetail(data), + data, + }; + if (existingIndex === undefined) { + byKey.set(next.id, activities.length); + activities.push(next); + } else { + const previous = activities[existingIndex]; + activities[existingIndex] = { + ...previous, + ...next, + title: next.title === eventTitle(kind, {}) ? previous.title : next.title, + detail: next.detail || previous.detail, + }; + } + } + + return { reasoning, output, activities }; +} + +function tokenSummary(data: Record): string { + const total = Number(data.totalTokens ?? data.total_tokens ?? 0); + return `${Number.isFinite(total) ? total.toLocaleString("en-US") : "0"} tokens`; +} + +function durationSummary(data: Record): string { + const duration = Number(data.durationMs ?? data.duration_ms); + if (!Number.isFinite(duration) || duration < 0) return ""; + return duration < 1000 ? `${Math.round(duration)}ms` : `${(duration / 1000).toFixed(1)}s`; +} + +/** Turn the persisted event stream into a compact, stable run-inspector timeline. */ +export function projectRunInspectorTimeline(events: RunEvent[]): RunInspectorTimelineItem[] { + const timeline: RunInspectorTimelineItem[] = []; + const indexes = new Map(); + const hasStarted = events.some(event => event.type === "run.started"); + + const upsert = (key: string, item: RunInspectorTimelineItem) => { + const index = indexes.get(key); + if (index === undefined) { + indexes.set(key, timeline.length); + timeline.push(item); + return; + } + timeline[index] = { ...timeline[index], ...item }; + }; + + for (const event of events) { + const data = event.data || {}; + const type = event.type || ""; + + if (type === "run.created") { + if (hasStarted) continue; + timeline.push({ + id: `run:${event.id}`, + kind: "run", + title: "Run 创建", + summary: String(data.model || data.runtimeType || ""), + detail: "", + status: "running", + createdAt: event.createdAt, + data, + }); + continue; + } + if (type === "run.started") { + const runtimeEvent = recordOf(data.runtimeEvent); + timeline.push({ + id: `run:${event.id}`, + kind: "run", + title: "Run 启动", + summary: String(data.runtimeType || runtimeEvent.runtimeType || runtimeEvent.model || "Local Runtime"), + detail: "", + status: "running", + createdAt: event.createdAt, + data, + }); + continue; + } + if (["run.completed", "run.failed", "run.interrupted", "run.cancelled", "run.canceled"].includes(type)) { + const failed = type === "run.failed" || type === "run.interrupted"; + const cancelled = type === "run.cancelled" || type === "run.canceled"; + timeline.push({ + id: `run:${event.id}`, + kind: "run", + title: failed ? (type === "run.interrupted" ? "Run 中断" : "Run 失败") : cancelled ? "Run 取消" : "Run 完成", + summary: durationSummary(data), + detail: failed ? String(data.error || data.message || "") : "", + status: failed ? "failed" : "completed", + createdAt: event.createdAt, + data, + }); + continue; + } + + if (type.startsWith("thinking.") || type.startsWith("message.")) { + const kind = type.startsWith("thinking.") ? "thinking" : "message"; + const key = `stream:${kind}`; + const previousIndex = indexes.get(key); + const previous = previousIndex === undefined ? null : timeline[previousIndex]; + const text = String(data.text || data.delta || ""); + const completed = type.endsWith(".completed"); + const detail = completed && text ? text : `${previous?.detail || ""}${text}`; + upsert(key, { + id: key, + kind, + title: kind === "thinking" ? "思考过程" : "模型回复", + summary: detail ? `${detail.length} 字` : "", + detail, + status: completed ? "completed" : "running", + createdAt: event.createdAt || previous?.createdAt, + data, + }); + continue; + } + + let activityKind: RunInspectorTimelineItem["kind"] | null = null; + if (type.startsWith("command.")) activityKind = "command"; + else if (type.startsWith("tool.")) activityKind = "tool"; + else if (type.startsWith("approval.")) activityKind = "approval"; + if (activityKind) { + const key = `${activityKind}:${callKey(data, String(event.id))}`; + const previousIndex = indexes.get(key); + const previous = previousIndex === undefined ? null : timeline[previousIndex]; + const failed = type.endsWith(".failed") || Boolean(data.error) + || Number(data.exitCode ?? data.exit_code ?? 0) !== 0; + const completed = type.endsWith(".completed") || type.endsWith(".resolved"); + const detail = eventDetail(data) || previous?.detail || ""; + const nextTitle = eventTitle(activityKind, data); + const defaultTitle = eventTitle(activityKind, {}); + upsert(key, { + id: key, + kind: activityKind, + title: nextTitle === defaultTitle && previous?.title ? previous.title : nextTitle, + summary: durationSummary(data), + detail, + status: failed ? "failed" : completed ? "completed" : activityKind === "approval" ? "waiting" : "running", + createdAt: event.createdAt || previous?.createdAt, + data, + }); + continue; + } + + if (type === "usage.reported") { + timeline.push({ + id: `usage:${event.id}`, + kind: "usage", + title: "用量上报", + summary: tokenSummary(data), + detail: "", + status: "completed", + createdAt: event.createdAt, + data, + }); + } + } + + const terminal = [...events].reverse().find(event => [ + "run.completed", "run.failed", "run.interrupted", "run.cancelled", "run.canceled", + ].includes(event.type)); + if (terminal) { + for (const item of timeline) { + if (item.status === "running" && (item.kind === "thinking" || item.kind === "message")) { + item.status = "completed"; + } + } + } + return timeline; +} diff --git a/ksadk/studio/react-ui/src/components/A2UIRenderer.test.tsx b/ksadk/studio/react-ui/src/components/A2UIRenderer.test.tsx new file mode 100644 index 00000000..298e081d --- /dev/null +++ b/ksadk/studio/react-ui/src/components/A2UIRenderer.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { A2UIRenderer } from "./A2UIRenderer"; + +describe("A2UIRenderer", () => { + it("renders an approval surface and submits a structured action", async () => { + const submit = vi.fn(); + render( + , + ); + + expect(screen.getByText("需要你的确认")).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "批准" })); + expect(submit).toHaveBeenCalledWith("approval-1", "approve", {}); + }); + + it("collects multi-select and custom text input values", async () => { + const submit = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByLabelText("A")); + await userEvent.type(screen.getByLabelText("补充说明"), "仅检查"); + await userEvent.click(screen.getByRole("button", { name: "继续" })); + expect(submit).toHaveBeenCalledWith("interaction-1", "submit", { + targets: ["A"], + note: "仅检查", + }); + }); + + it("renders quiet described choices and submits a free-form other answer", async () => { + const submit = vi.fn(); + render( + , + ); + + expect(screen.getByText("只检查 React。")).toBeVisible(); + await userEvent.type(screen.getByLabelText("检查范围自定义输入"), "只检查协议层"); + await userEvent.click(screen.getByRole("button", { name: /提交/ })); + + expect(submit).toHaveBeenCalledWith("question-1", "submit", { + scope: "只检查协议层", + }); + }); + + it("applies streamed data-model updates without overwriting a field the user edited", async () => { + const submit = vi.fn(); + const surface = { + id: "surface-streamed", + catalogId: "basic", + roots: ["form"], + dataModel: { summary: "等待更新", note: "服务端默认" }, + components: { + form: { id: "form", component: "Form", children: ["summary", "note"] }, + summary: { id: "summary", component: "TextField", name: "summary", label: "摘要" }, + note: { id: "note", component: "TextField", name: "note", label: "补充" }, + }, + interaction: { id: "interaction-streamed", kind: "form", status: "pending" as const, inputSchema: {} }, + }; + const view = render(); + await userEvent.clear(screen.getByLabelText("补充")); + await userEvent.type(screen.getByLabelText("补充"), "用户输入"); + + view.rerender( + , + ); + + expect(screen.getByLabelText("摘要")).toHaveValue("流式更新完成"); + expect(screen.getByLabelText("补充")).toHaveValue("用户输入"); + }); +}); diff --git a/ksadk/studio/react-ui/src/components/A2UIRenderer.tsx b/ksadk/studio/react-ui/src/components/A2UIRenderer.tsx new file mode 100644 index 00000000..5ca6ef1b --- /dev/null +++ b/ksadk/studio/react-ui/src/components/A2UIRenderer.tsx @@ -0,0 +1,230 @@ +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { Check, CornerDownLeft, Pencil, Shield, X } from "lucide-react"; +import type { A2UIComponent, A2UISurface } from "../chatProtocol"; +import { StudioSelect } from "./ui/StudioSelect"; + +interface A2UIRendererProps { + surface: A2UISurface; + busy?: boolean; + onSubmit: (interactionId: string, name: string, data: Record) => void | Promise; +} + +function optionsOf(value: unknown): Array<{ label: string; value: string; description: string }> { + if (!Array.isArray(value)) return []; + return value.map(item => { + if (typeof item === "string") return { label: item, value: item, description: "" }; + const record = item && typeof item === "object" ? item as Record : {}; + const optionValue = String(record.value ?? record.id ?? record.label ?? ""); + return { + label: String(record.label ?? record.title ?? optionValue), + value: optionValue, + description: String(record.description ?? record.help ?? ""), + }; + }).filter(item => item.value); +} + +function childIds(component: A2UIComponent): string[] { + const raw = Array.isArray(component.children) + ? component.children + : typeof component.child === "string" ? [component.child] : []; + return raw.filter((value): value is string => typeof value === "string"); +} + +export function A2UIRenderer({ surface, busy = false, onSubmit }: A2UIRendererProps) { + const [values, setValues] = useState>(() => ({ ...surface.dataModel })); + const [customValues, setCustomValues] = useState>({}); + const dirtyFields = useRef(new Set()); + const pending = surface.interaction?.status === "pending"; + const disabled = busy || !pending; + const roots = surface.roots.length ? surface.roots : Object.keys(surface.components).slice(0, 1); + + useEffect(() => { + setValues(current => { + const next = { ...current }; + for (const [field, value] of Object.entries(surface.dataModel)) { + if (!dirtyFields.current.has(field)) next[field] = value; + } + return next; + }); + }, [surface.dataModel]); + + const updateField = (name: string, value: unknown) => { + dirtyFields.current.add(name); + setValues(current => ({ ...current, [name]: value })); + }; + + const submit = (name: string, extra: Record = {}) => { + if (!surface.interaction || disabled) return; + const data = { ...values }; + for (const [field, customValue] of Object.entries(customValues)) { + const trimmed = customValue.trim(); + if (!trimmed) continue; + const current = data[field]; + data[field] = Array.isArray(current) + ? [...current.filter(value => String(value) !== trimmed), trimmed] + : trimmed; + } + void onSubmit(surface.interaction.id, name, { ...data, ...extra }); + }; + + const renderChoiceGroup = ( + component: A2UIComponent, + multiple: boolean, + ) => { + const name = String(component.name || component.id); + const options = optionsOf(component.options); + const selectedMany = Array.isArray(values[name]) ? values[name] as string[] : []; + const selectedOne = String(values[name] ?? component.value ?? ""); + const allowOther = Boolean(component.allow_other ?? component.allowOther ?? component.is_other ?? component.isOther); + const customValue = customValues[name] ?? ""; + return ( +
+ {String(component.label || component.title || "请选择")} + {Boolean(component.description) &&

{String(component.description)}

} +
+ {options.map((option, index) => { + const isSelected = multiple ? selectedMany.includes(option.value) : selectedOne === option.value; + return ( + + ); + })} + {allowOther && ( + + )} +
+
+ ); + }; + + const renderNode = (componentId: string): ReactNode => { + const component = surface.components[componentId]; + if (!component) return null; + const type = component.component; + const children = childIds(component).map(child =>
{renderNode(child)}
); + if (type === "Card") { + return ( +
+ {Boolean(component.title) &&

{String(component.title)}

} + {Boolean(component.body) &&

{String(component.body)}

} + {children.length > 0 &&
{children}
} +
+ ); + } + if (["Column", "Row"].includes(type)) { + return
{children}
; + } + if (type === "Text") return

{String(component.text || "")}

; + if (["TextField", "Input"].includes(type)) { + const name = String(component.name || component.id); + return ( + + ); + } + if (["Select", "RadioGroup", "MultipleChoice"].includes(type)) { + const name = String(component.name || component.id); + const options = optionsOf(component.options); + const multiple = type === "MultipleChoice" && Boolean(component.multiple); + if (type === "RadioGroup" || type === "MultipleChoice") return renderChoiceGroup(component, multiple); + return ( +
+ {String(component.label || component.title || "请选择")} + updateField(name, value)} + /> +
+ ); + } + if (type === "CheckboxGroup") { + return renderChoiceGroup(component, true); + } + if (type === "ApprovalBar") { + return ( +
+ {String(component.summary || component.tool_name || "请确认此操作")} + + + + +
+ ); + } + if (type === "Form") { + return ( +
{ event.preventDefault(); submit("submit"); }}> + {Boolean(component.title) && {String(component.title)}} + {children} +
+ +
+
+ ); + } + if (type === "Button") { + const action = String(component.action || component.name || component.id || "submit"); + return ; + } + return
此卡片包含暂不支持的组件:{type || "unknown"}
; + }; + + const content = roots.map(root =>
{renderNode(root)}
); + return ( +
+ {content} + {surface.interaction && !pending &&
已提交
} +
+ ); +} diff --git a/ksadk/studio/react-ui/src/components/AgentAppearanceEditor.tsx b/ksadk/studio/react-ui/src/components/AgentAppearanceEditor.tsx new file mode 100644 index 00000000..425b4e0c --- /dev/null +++ b/ksadk/studio/react-ui/src/components/AgentAppearanceEditor.tsx @@ -0,0 +1,253 @@ +import { useEffect, useRef, useState } from "react"; +import Cropper, { type Area } from "react-easy-crop"; +import { Bot, Code2, ImagePlus, Network, Search, Sparkles, Trash2 } from "lucide-react"; +import { apiFetch } from "../api"; +import { AgentAvatar, type AgentAppearance } from "./AgentAvatar"; +import { StudioDialog } from "./ui/StudioDialog"; + +const ICONS = [ + { id: "bot", label: "Bot", icon: Bot }, + { id: "sparkles", label: "Sparkles", icon: Sparkles }, + { id: "search", label: "Search", icon: Search }, + { id: "code", label: "Code", icon: Code2 }, + { id: "workflow", label: "Workflow", icon: Network }, +] as const; + +const COLORS = [ + { value: "#426ea8", label: "云蓝" }, + { value: "#7c5cc4", label: "紫罗兰" }, + { value: "#2d7c68", label: "松石绿" }, + { value: "#a86d32", label: "琥珀" }, + { value: "#a55267", label: "玫瑰" }, + { value: "#526173", label: "石墨" }, +] as const; + +function normalizeAppearance(appearance?: AgentAppearance): Required { + return { + icon: appearance?.icon || "bot", + color: appearance?.color || "#426ea8", + imageUrl: appearance?.imageUrl || null, + }; +} + +async function loadImage(source: string): Promise { + const image = new Image(); + image.decoding = "async"; + image.src = source; + await image.decode(); + return image; +} + +async function cropToWebp(source: string, area: Area): Promise { + const image = await loadImage(source); + const canvas = document.createElement("canvas"); + canvas.width = 512; + canvas.height = 512; + const context = canvas.getContext("2d"); + if (!context) throw new Error("当前浏览器无法创建头像画布"); + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.drawImage( + image, + area.x, + area.y, + area.width, + area.height, + 0, + 0, + canvas.width, + canvas.height, + ); + const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/webp", 0.9)); + if (!blob) throw new Error("头像裁剪失败,请更换图片后重试"); + return blob; +} + +export function AgentAppearanceEditor({ + name, + appearance, + disabled = false, + onSave, +}: { + name: string; + appearance?: AgentAppearance; + disabled?: boolean; + onSave: (appearance: Required) => Promise; +}) { + const [draft, setDraft] = useState(() => normalizeAppearance(appearance)); + const [sourceUrl, setSourceUrl] = useState(null); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [cropArea, setCropArea] = useState(null); + const [uploading, setUploading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const inputRef = useRef(null); + + useEffect(() => setDraft(normalizeAppearance(appearance)), [appearance]); + useEffect(() => () => { + if (sourceUrl) URL.revokeObjectURL(sourceUrl); + }, [sourceUrl]); + + const persisted = normalizeAppearance(appearance); + const dirty = draft.icon !== persisted.icon + || draft.color !== persisted.color + || draft.imageUrl !== persisted.imageUrl; + + function selectFile(file?: File) { + setError(""); + if (!file) return; + if (!(["image/png", "image/webp"] as string[]).includes(file.type)) { + setError("仅支持 PNG 或 WebP 图片"); + return; + } + if (file.size > 2 * 1024 * 1024) { + setError("头像文件不能超过 2 MiB"); + return; + } + setCrop({ x: 0, y: 0 }); + setZoom(1); + setCropArea(null); + setSourceUrl(URL.createObjectURL(file)); + } + + async function applyCrop() { + if (!sourceUrl || !cropArea || uploading) return; + setUploading(true); + setError(""); + try { + const blob = await cropToWebp(sourceUrl, cropArea); + const response = await apiFetch("/api/v1/assets/agent-avatars", { + method: "POST", + headers: { "Content-Type": blob.type }, + body: blob, + }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payload?.error?.message || `头像上传失败(${response.status})`); + setDraft(current => ({ ...current, imageUrl: payload.url })); + setSourceUrl(null); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "头像上传失败"); + } finally { + setUploading(false); + } + } + + async function saveAppearance() { + if (!dirty || saving || disabled) return; + setSaving(true); + setError(""); + try { + await onSave(draft); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "外观保存失败"); + } finally { + setSaving(false); + } + } + + return ( +
+
+ +
Agent 外观用于列表、会话和 Trace;不会写入模型提示词。
+
+
+
+ 图标 +
+ {ICONS.map(item => ( + + ))} +
+
+
+ 配色 +
+ {COLORS.map(item => ( +
+
+
+
+ { selectFile(event.target.files?.[0]); event.target.value = ""; }} + /> + + {draft.imageUrl ? ( + + ) : null} + +
+ {error ?

{error}

: null} + + { if (!open && !uploading) setSourceUrl(null); }} + title="调整 Agent 头像" + description="拖动画面并缩放,保存后会生成 512 × 512 WebP。" + closeDisabled={uploading} + className="avatar-crop-dialog" + footer={( + <> + + + + )} + > + {sourceUrl ? ( + <> +
+ setCropArea(pixels)} + /> +
+ + + ) : null} +
+
+ ); +} diff --git a/ksadk/studio/react-ui/src/components/AgentAvatar.tsx b/ksadk/studio/react-ui/src/components/AgentAvatar.tsx new file mode 100644 index 00000000..c9a56c9f --- /dev/null +++ b/ksadk/studio/react-ui/src/components/AgentAvatar.tsx @@ -0,0 +1,48 @@ +import type { CSSProperties } from "react"; +import { Bot, Code2, Network, Search, Sparkles } from "lucide-react"; + +export interface AgentAppearance { + icon?: "bot" | "sparkles" | "search" | "code" | "workflow"; + color?: string; + imageUrl?: string | null; +} + +const iconComponents = { + bot: Bot, + sparkles: Sparkles, + search: Search, + code: Code2, + workflow: Network, +}; + +export function AgentAvatar({ + name, + appearance, + template, + size = "md", + className = "", +}: { + name: string; + appearance?: AgentAppearance; + template?: string; + size?: "xs" | "sm" | "md" | "lg"; + className?: string; +}) { + const icon = appearance?.icon || (template === "research" ? "search" : "bot"); + const Icon = iconComponents[icon]; + const color = appearance?.color || (template === "research" ? "#2d7c68" : "#426ea8"); + const style = { "--agent-avatar-color": color } as CSSProperties; + + return ( + + {appearance?.imageUrl + ? + : } + + ); +} diff --git a/ksadk/studio/react-ui/src/components/ChatRunPanel.tsx b/ksadk/studio/react-ui/src/components/ChatRunPanel.tsx new file mode 100644 index 00000000..af65c052 --- /dev/null +++ b/ksadk/studio/react-ui/src/components/ChatRunPanel.tsx @@ -0,0 +1,301 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Activity, + ArrowRight, + BrainCircuit, + CheckCircle2, + Clock3, + Coins, + Cpu, + Gauge, + GitBranch, + Loader2, + MessageSquare, + Play, + RefreshCw, + ShieldCheck, + Terminal, + Wrench, + X, + XCircle, +} from "lucide-react"; +import { apiFetch } from "../api"; +import { + createResponseSseParser, + projectRunInspectorTimeline, + type RunEvent, + type RunInspectorTimelineItem, +} from "../chatProtocol"; + +interface RunRecord { + id: string; + agentId: string; + sessionId: string; + traceId: string; + model: string; + runtimeType?: string; + status: string; + startedAt?: string; + completedAt?: string; + durationMs?: number | null; + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + reported?: boolean; + }; + error?: { code?: string; message?: string } | null; +} + +interface Span { + spanId: string; + parentSpanId?: string; + name: string; + kind: string; + status: string; + durationMs: number; + startTimeUnixNano?: string; +} + +interface WaterfallSpan extends Span { + left: number; + width: number; + depth: number; +} + +function fmtDuration(ms?: number | null): string { + if (!Number.isFinite(ms) || Number(ms) < 0) return "未上报"; + if (Number(ms) < 1000) return `${Math.round(Number(ms))}ms`; + return `${(Number(ms) / 1000).toFixed(2)}s`; +} + +function fmtTokens(value?: number): string { + return Number.isFinite(value) ? Number(value).toLocaleString() : "未上报"; +} + +function shortId(id: string): string { + return id.length > 20 ? `${id.slice(0, 17)}…` : id; +} + +function statusLabel(status: string): string { + if (status === "RUNNING" || status === "CREATED") return "运行中"; + if (status === "COMPLETED") return "已完成"; + if (status === "CANCELLED") return "已取消"; + if (status === "INTERRUPTED") return "已中断"; + if (status === "TIMED_OUT") return "已超时"; + return "失败"; +} + +function startNs(span: Span): bigint { + try { return BigInt(String(span.startTimeUnixNano || "0")); } catch { return 0n; } +} + +function waterfallLayout(spans: Span[]): WaterfallSpan[] { + if (!spans.length) return []; + const ordered = [...spans].sort((left, right) => startNs(left) < startNs(right) ? -1 : 1); + const base = ordered.reduce((minimum, span) => { + const value = startNs(span); + return !minimum || value < minimum ? value : minimum; + }, 0n); + const offsets = ordered.map(span => Number(startNs(span) - base) / 1_000_000); + const total = Math.max(1, ...ordered.map((span, index) => offsets[index] + Math.max(0, Number(span.durationMs) || 0))); + const byId = new Map(ordered.map(span => [span.spanId, span])); + const depthOf = (span: Span) => { + let depth = 0; + let parentId = span.parentSpanId; + const seen = new Set(); + while (parentId && byId.has(parentId) && !seen.has(parentId) && depth < 3) { + seen.add(parentId); + depth += 1; + parentId = byId.get(parentId)?.parentSpanId; + } + return depth; + }; + return ordered.slice(0, 8).map((span, index) => ({ + ...span, + left: Math.min(96, Math.max(0, offsets[index] / total * 100)), + width: Math.max(3, Math.min(100, Math.max(0, Number(span.durationMs) || 0) / total * 100)), + depth: depthOf(span), + })); +} + +function timelineIcon(item: RunInspectorTimelineItem) { + if (item.kind === "thinking") return ; + if (item.kind === "message") return ; + if (item.kind === "command") return ; + if (item.kind === "tool") return ; + if (item.kind === "approval") return ; + if (item.kind === "usage") return ; + return ; +} + +async function loadEvents(runId: string): Promise { + const response = await apiFetch(`/api/v1/runs/${encodeURIComponent(runId)}/events`); + if (!response.ok) return []; + const parsed: RunEvent[] = []; + const parser = createResponseSseParser(event => { + const { type, ...data } = event; + parsed.push({ id: parsed.length + 1, type, data }); + }); + parser.push(await response.text()); + parser.finish(); + return parsed; +} + +/** 会话页右侧运行检查器:状态、用量、Trace 瀑布与压缩事件时间线。 */ +export function ChatRunPanel({ agentId, onOpenTrace, onClose }: { agentId: string; onOpenTrace: () => void; onClose: () => void }) { + const [latest, setLatest] = useState(null); + const [events, setEvents] = useState([]); + const [spans, setSpans] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshKey, setRefreshKey] = useState(0); + + useEffect(() => { + let cancelled = false; + let timer: number | null = null; + let requestId = 0; + async function load() { + const currentRequest = ++requestId; + try { + const runPayload = await apiFetch("/api/v1/runs").then(response => response.json()); + const matches: RunRecord[] = (runPayload.items || []).filter((run: RunRecord) => !agentId || run.agentId === agentId); + const current = matches.at(-1) || null; + if (cancelled || currentRequest !== requestId) return; + setLatest(current); + if (!current) { + setEvents([]); + setSpans([]); + return; + } + const [nextEvents, trace] = await Promise.all([ + loadEvents(current.id), + apiFetch(`/api/v1/traces/${encodeURIComponent(current.traceId)}`).then(response => response.ok ? response.json() : null).catch(() => null), + ]); + if (!cancelled && currentRequest === requestId) { + setEvents(nextEvents); + setSpans(trace?.spans || []); + } + } catch { + // Inspector 是增强视图,保留上次可用数据,避免遮断会话。 + } finally { + if (!cancelled && currentRequest === requestId) setLoading(false); + if (!cancelled) timer = window.setTimeout(load, 2500); + } + } + setLoading(true); + void load(); + return () => { + cancelled = true; + requestId += 1; + if (timer !== null) window.clearTimeout(timer); + }; + }, [agentId, refreshKey]); + + const running = latest?.status === "RUNNING" || latest?.status === "CREATED"; + const failed = latest ? /fail|error|interrupt|timed/i.test(latest.status) : false; + const timeline = useMemo(() => projectRunInspectorTimeline(events), [events]); + const waterfall = useMemo(() => waterfallLayout(spans), [spans]); + const usageReported = latest?.usage?.reported === true; + + return ( + + ); +} diff --git a/ksadk/studio/react-ui/src/components/ChatWorkspace.tsx b/ksadk/studio/react-ui/src/components/ChatWorkspace.tsx new file mode 100644 index 00000000..94de1701 --- /dev/null +++ b/ksadk/studio/react-ui/src/components/ChatWorkspace.tsx @@ -0,0 +1,1191 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { + Bot, + BrainCircuit, + Check, + ChevronDown, + FileText, + Hand, + ListTodo, + Loader2, + MessageSquarePlus, + Pause, + Play, + Send, + ShieldAlert, + ShieldCheck, + Terminal, + Trash2, + Wrench, + X, +} from "lucide-react"; +import { apiFetch } from "../api"; +import { + APPROVAL_MODES, + approvalModeOption, + approvalModeStorageKey, + normalizeApprovalMode, + type ApprovalMode, +} from "../approvalModes"; +import { + createChatStreamState, + createResponseSseParser, + groupRunsBySession, + latestActiveRun, + persistedRunsForDisplay, + projectA2UISurfaces, + projectRunActivities, + reduceChatStreamEvent, + contextUsageState, + contextUsageTooltip, + latestReportedInputTokens, + type ChatRun, + type ChatStreamState, + type RunActivity, + type RunEvent, +} from "../chatProtocol"; +import { A2UIRenderer } from "./A2UIRenderer"; +import { AgentAvatar, type AgentAppearance } from "./AgentAvatar"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { showToast } from "./Toast"; +import { + buildResponsesInput, + encodedComposerAttachmentsBytes, + fileToComposerAttachment, + formatAttachmentSize, + MAX_COMPOSER_ATTACHMENT_BYTES, + MAX_COMPOSER_ATTACHMENTS, + parseComposerSubmission, + visibleComposerCommands, + type CollaborationMode, + type ComposerAttachment, + type ComposerCommand, +} from "../composerActions"; +import { ComposerActionMenu, ComposerCommandMenu } from "./ComposerActionMenu"; +import { RuntimeModeBar, type RuntimeMode, type RuntimeModeStatus } from "./RuntimeModeBar"; + +interface ChatModel { + id: string; + display_name?: string; + displayName?: string; + context_window_tokens?: number; + contextWindowTokens?: number; +} + +interface ChatWorkspaceProps { + agentId: string; + agentName: string; + agentAppearance?: AgentAppearance; + onRunChanged?: () => void; +} + +function ApprovalModeMenu({ + value, + onChange, +}: { + value: ApprovalMode; + onChange: (value: ApprovalMode) => void; +}) { + const selected = approvalModeOption(value); + const icon = value === "ask" + ? + : value === "full" + ? + : ; + return ( + + + + + + +
+ 如何批准 Agent 操作? + 下一轮生效 +
+ onChange(normalizeApprovalMode(next))} + > + {APPROVAL_MODES.map(option => ( + + + {option.value === "ask" ? + : option.value === "full" ? + : } + + + {option.label} + {option.description} + + + + + + ))} + +
+
+
+ ); +} + +function ContextRing({ + usedTokens, + limitTokens, + known, + percent, +}: ReturnType) { + const tooltipId = useId(); + const tooltip = contextUsageTooltip({ usedTokens, limitTokens, known, percent }); + const accessibleLabel = `${tooltip.title}:${tooltip.value},${tooltip.detail}`; + return ( + + + + {tooltip.title} + {tooltip.value} + {tooltip.detail} + + + ); +} + +function ModelMenu({ + models, + value, + disabled, + onChange, +}: { + models: ChatModel[]; + value: string; + disabled: boolean; + onChange: (value: string) => void; +}) { + const selected = models.find(item => item.id === value); + const label = selected?.display_name || selected?.displayName || selected?.id || "默认模型"; + return ( + + + + + + +
选择下一轮使用的模型
+ + {models.map(item => { + const itemLabel = item.display_name || item.displayName || item.id; + return ( + + {itemLabel} + + + ); + })} + +
+
+
+ ); +} + +function uniqueId(prefix: string): string { + const value = typeof crypto.randomUUID === "function" + ? crypto.randomUUID().replaceAll("-", "") + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; + return `${prefix}_${value}`; +} + +function formatSessionTime(value: string): string { + if (!value) return "刚刚"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const today = new Date(); + if (date.toDateString() === today.toDateString()) { + return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit" }).format(date); + } + return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit" }).format(date); +} + +function formatMessageTime(value?: string): string { + if (!value) return "刚刚"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function shortText(value: string, limit = 34): string { + const normalized = value.replace(/\s+/g, " ").trim(); + return normalized.length > limit ? `${normalized.slice(0, limit)}…` : normalized; +} + +async function responseError(response: Response): Promise { + const payload = await response.clone().json().catch(() => null); + return payload?.error?.message + || payload?.Message + || payload?.message + || `请求失败(HTTP ${response.status})`; +} + +function activityIcon(kind: RunActivity["kind"]) { + if (kind === "command") return ; + if (kind === "approval") return ; + return ; +} + +function activityLabel(activity: RunActivity): string { + if (activity.kind === "command") return "命令"; + if (activity.kind === "approval") return "人工确认"; + return "工具"; +} + +function reasoningPreview(value: string): string { + const normalized = value.replace(/```[\s\S]*?```/g, " ").replace(/[`#>*_[\]()-]+/g, " ").replace(/\s+/g, " ").trim(); + if (!normalized) return ""; + const parts = normalized.split(/[。!?!?]+|\.(?=\s|$)/).map(item => item.trim()).filter(Boolean); + return shortText(parts.at(-1) || normalized, 72); +} + +function ProcessingGroup({ + reasoning, + activities, + streaming = false, +}: { + reasoning: string; + activities: RunActivity[]; + streaming?: boolean; +}) { + if (!reasoning && activities.length === 0) return null; + const running = activities.find(activity => activity.status === "running" || activity.status === "waiting"); + const title = streaming + ? running ? `${running.status === "waiting" ? "等待确认" : "正在处理"} · ${running.title}` + : reasoningPreview(reasoning) ? `正在思考 · ${reasoningPreview(reasoning)}` + : "正在思考" + : activities.length > 0 ? `已处理 ${activities.length} 次工具调用` + : "查看思考过程"; + return ( +
+ + + {title} + {streaming && } + + +
+ {reasoning &&
{reasoning}
} + {activities.map(activity => )} +
+
+ ); +} + +function ActivityCard({ activity }: { activity: RunActivity }) { + const expandable = Boolean(activity.detail) || Object.keys(activity.data).length > 2; + const row = ( + <> + {activityIcon(activity.kind)} + + {activityLabel(activity)} + {activity.title} + + { + activity.status === "completed" ? "已完成" + : activity.status === "failed" ? "失败" + : activity.status === "waiting" ? "等待确认" + : "运行中" + } + + ); + if (!expandable) { + return
{row}
; + } + return ( +
+ + {row} + + +
{activity.detail || JSON.stringify(activity.data, null, 2)}
+
+ ); +} + +function RunActivityCards({ + runId, + status, + showOutput = false, + onInteraction, +}: { + runId: string; + status?: string; + showOutput?: boolean; + onInteraction: (runId: string, interactionId: string, name: string, data: Record) => Promise; +}) { + const [events, setEvents] = useState([]); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const response = await apiFetch(`/api/v1/runs/${encodeURIComponent(runId)}/events`); + if (!response.ok) throw new Error(await responseError(response)); + const parsed: RunEvent[] = []; + const parser = createResponseSseParser(event => { + const { type, ...data } = event; + parsed.push({ id: parsed.length + 1, type, data }); + }); + parser.push(await response.text()); + parser.finish(); + if (!cancelled) setEvents(parsed); + } catch { + // 事件卡片是增强信息;历史正文仍然可以独立展示。 + } finally { + if (!cancelled) setLoaded(true); + } + } + load(); + const timer = ["RUNNING", "PAUSED", "WAITING_INPUT"].includes(String(status)) ? window.setInterval(load, 500) : null; + return () => { + cancelled = true; + if (timer !== null) window.clearInterval(timer); + }; + }, [runId, status]); + + const projection = useMemo(() => projectRunActivities(events), [events]); + const surfaces = useMemo(() => projectA2UISurfaces(events), [events]); + if (!loaded && ["RUNNING", "PAUSED", "WAITING_INPUT"].includes(String(status))) { + return
正在读取运行事件
; + } + if (!projection.reasoning && !projection.output && projection.activities.length === 0 && surfaces.length === 0) { + return status === "RUNNING" + ? + : null; + } + return ( + <> + + {surfaces.map(surface => ( + onInteraction(runId, interactionId, name, data)} + /> + ))} + {showOutput && projection.output && {projection.output}} + + ); +} + +function MarkdownMessage({ children, streaming = false }: { children: string; streaming?: boolean }) { + return ( +
+ {label}, + code: ({ className, children: code }) => {code}, + }} + > + {children} + +
+ ); +} + +function PersistedTurn({ + run, + agentName, + agentAppearance, + onInteraction, +}: { + run: ChatRun; + agentName: string; + agentAppearance?: AgentAppearance; + onInteraction: (runId: string, interactionId: string, name: string, data: Record) => Promise; +}) { + const failed = run.status && !["COMPLETED", "RUNNING", "PAUSED", "WAITING_INPUT"].includes(run.status); + const assistantText = run.output + || run.error?.message + || (failed ? `运行状态:${run.status}` : ""); + return ( + <> +
+
{formatMessageTime(run.startedAt)}
+
{run.input}
+
+
+
+ + {agentName} + {formatMessageTime(run.completedAt || run.startedAt)} + {run.model && {run.model}} +
+
+ + {["RUNNING", "PAUSED", "WAITING_INPUT"].includes(String(run.status)) ? null : failed ? ( + {assistantText} + ) : assistantText ? ( + {assistantText} + ) : null} +
+
+ + ); +} + +function StreamingTurn({ + prompt, + stream, + agentName, + agentAppearance, + onInteraction, +}: { + prompt: string; + stream: ChatStreamState; + agentName: string; + agentAppearance?: AgentAppearance; + onInteraction: (runId: string, interactionId: string, name: string, data: Record) => Promise; +}) { + return ( + <> +
+
刚刚
+
{prompt}
+
+
+
+ + {agentName} + {stream.status === "streaming" ? "正在生成" : "刚刚"} +
+
+ + {stream.surfaces.map(surface => ( + onInteraction(stream.runId, interactionId, name, data)} + /> + ))} + {stream.output ? {stream.output} : stream.error ? ( + {stream.error} + ) : stream.status === "cancelled" ? ( + 运行已停止 + ) : ( + + )} +
+
+ + ); +} + +export function ChatWorkspace({ agentId, agentName, agentAppearance, onRunChanged }: ChatWorkspaceProps) { + const [runs, setRuns] = useState([]); + const [models, setModels] = useState([]); + const [model, setModel] = useState(""); + const [currentSessionId, setCurrentSessionId] = useState(""); + const [query, setQuery] = useState(""); + const [input, setInput] = useState(""); + const [approvalMode, setApprovalMode] = useState("risk"); + const [collaborationMode, setCollaborationMode] = useState("default"); + const [attachments, setAttachments] = useState([]); + const [commandIndex, setCommandIndex] = useState(0); + const [stream, setStream] = useState(null); + const [optimisticPrompt, setOptimisticPrompt] = useState(""); + const [loading, setLoading] = useState(true); + const [deleteSessionId, setDeleteSessionId] = useState(""); + const [deleting, setDeleting] = useState(false); + const messageListRef = useRef(null); + const textareaRef = useRef(null); + const abortRef = useRef(null); + const followBottomRef = useRef(true); + const scrollBySessionRef = useRef(new Map()); + + const sessions = useMemo(() => groupRunsBySession(runs, agentId), [runs, agentId]); + const filteredSessions = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase(); + return normalized + ? sessions.filter(session => session.title.toLocaleLowerCase().includes(normalized)) + : sessions; + }, [query, sessions]); + const currentSession = sessions.find(session => session.id === currentSessionId); + const visibleRuns = currentSession?.runs || []; + const persistedActiveRun = latestActiveRun(visibleRuns); + const activeStatus = stream?.status || String(persistedActiveRun?.status || "").toLowerCase(); + const isGenerating = ["streaming", "paused", "waiting_input"].includes(activeStatus) || Boolean(persistedActiveRun); + const displayRuns = persistedRunsForDisplay( + visibleRuns, + stream && optimisticPrompt && stream.sessionId === currentSessionId + ? persistedActiveRun?.id + : undefined, + ); + const activeStatusLabel = activeStatus === "paused" + ? "PAUSED" + : activeStatus === "waiting_input" + ? "WAITING" + : "RUNNING"; + const activeMode: RuntimeMode | null = stream?.goalObjective || persistedActiveRun?.goalObjective + ? "goal" + : (stream?.collaborationMode || persistedActiveRun?.collaborationMode) === "plan" + ? "plan" + : null; + const activeModeStatus: RuntimeModeStatus = activeStatus === "paused" + ? "paused" + : activeStatus === "waiting_input" + ? "waiting" + : "running"; + const activeModeObjective = stream?.goalObjective + || persistedActiveRun?.goalObjective + || optimisticPrompt + || persistedActiveRun?.input + || ""; + const activeModeStartedAt = stream?.startedAt || persistedActiveRun?.startedAt; + const activeModeElapsedMs = activeStatus === "paused" ? persistedActiveRun?.durationMs : undefined; + const selectedModel = models.find(item => item.id === model); + const streamUsage = stream?.usage as Record | undefined; + const contextUsage = contextUsageState( + streamUsage?.input_tokens + ?? streamUsage?.inputTokens + ?? latestReportedInputTokens(visibleRuns), + selectedModel?.context_window_tokens ?? selectedModel?.contextWindowTokens, + ); + const slashCommands = visibleComposerCommands(input); + + const refreshRuns = useCallback(async () => { + const runResponse = await apiFetch("/api/v1/runs"); + if (!runResponse.ok) throw new Error(await responseError(runResponse)); + const runPayload = await runResponse.json(); + const nextRuns: ChatRun[] = runPayload.items || []; + const nextSessions = groupRunsBySession(nextRuns, agentId); + setRuns(nextRuns); + setCurrentSessionId(previous => ( + previous && nextSessions.some(session => session.id === previous) + ? previous + : nextSessions[0]?.id || "" + )); + }, [agentId]); + + const loadWorkspace = useCallback(async () => { + const [, modelResponse] = await Promise.all([ + refreshRuns(), + apiFetch(`/api/v1/agents/${encodeURIComponent(agentId)}/models`), + ]); + if (!modelResponse.ok) throw new Error(await responseError(modelResponse)); + const modelPayload = await modelResponse.json(); + const nextModels: ChatModel[] = modelPayload.Models || []; + setModels(nextModels); + setModel(previous => { + if (nextModels.some(item => item.id === previous)) return previous; + return String(modelPayload.Current || nextModels[0]?.id || ""); + }); + }, [agentId, refreshRuns]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setRuns([]); + setCurrentSessionId(""); + loadWorkspace() + .catch(error => { if (!cancelled) showToast("会话加载失败", error.message, "error"); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { + cancelled = true; + abortRef.current?.abort(); + }; + }, [agentId, loadWorkspace]); // 只在切换 Agent 时重置;运行中的刷新由下方轮询负责。 + + useEffect(() => { + setApprovalMode(normalizeApprovalMode(localStorage.getItem(approvalModeStorageKey(agentId)))); + const storedMode = localStorage.getItem(`agentkit:chat:collaboration:${agentId}`); + setCollaborationMode(storedMode === "plan" ? "plan" : "default"); + setAttachments([]); + }, [agentId]); + + useEffect(() => { + setCommandIndex(0); + }, [input]); + + useEffect(() => { + if (!runs.some(run => ["RUNNING", "PAUSED", "WAITING_INPUT"].includes(String(run.status)))) return; + const timer = window.setInterval(() => { refreshRuns().catch(() => {}); }, 800); + return () => window.clearInterval(timer); + }, [runs, refreshRuns]); + + useEffect(() => { + const list = messageListRef.current; + if (!list || !followBottomRef.current) return; + list.scrollTop = list.scrollHeight; + }, [visibleRuns.length, stream?.output, stream?.reasoning, stream?.status, stream?.activities]); + + useEffect(() => { + const list = messageListRef.current; + if (!list) return; + const saved = currentSessionId ? scrollBySessionRef.current.get(currentSessionId) : undefined; + requestAnimationFrame(() => { + list.scrollTop = saved ?? list.scrollHeight; + followBottomRef.current = saved === undefined || list.scrollHeight - list.scrollTop - list.clientHeight < 48; + }); + }, [currentSessionId]); + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.style.height = "42px"; + textarea.style.height = `${Math.min(Math.max(textarea.scrollHeight, 42), 160)}px`; + }, [input]); + + function startNewSession() { + if (isGenerating) return; + setCurrentSessionId(""); + setOptimisticPrompt(""); + setStream(null); + setInput(""); + setAttachments([]); + followBottomRef.current = true; + requestAnimationFrame(() => textareaRef.current?.focus()); + } + + function selectSession(sessionId: string) { + const list = messageListRef.current; + if (list && currentSessionId) scrollBySessionRef.current.set(currentSessionId, list.scrollTop); + setCurrentSessionId(sessionId); + followBottomRef.current = !scrollBySessionRef.current.has(sessionId); + } + + function changeCollaborationMode(next: CollaborationMode) { + setCollaborationMode(next); + localStorage.setItem(`agentkit:chat:collaboration:${agentId}`, next); + } + + function togglePlanMode() { + const next = collaborationMode === "plan" ? "default" : "plan"; + changeCollaborationMode(next); + setInput(""); + showToast(next === "plan" ? "计划模式已开启" : "已返回默认模式", "下一轮对话生效", "success"); + requestAnimationFrame(() => textareaRef.current?.focus()); + } + + function selectComposerCommand(id: ComposerCommand["id"]) { + if (id === "goal") { + setInput("/goal "); + requestAnimationFrame(() => textareaRef.current?.focus()); + return; + } + if (id === "default") { + changeCollaborationMode("default"); + setInput(""); + showToast("已返回默认模式", "下一轮对话生效", "success"); + return; + } + togglePlanMode(); + } + + async function addAttachments(files: File[]) { + if (isGenerating) return; + const available = Math.max(0, MAX_COMPOSER_ATTACHMENTS - attachments.length); + if (!available) { + showToast("附件数量已达上限", `每轮最多 ${MAX_COMPOSER_ATTACHMENTS} 个`, "error"); + return; + } + const selected = files.slice(0, available); + const existingBytes = attachments.reduce((total, item) => total + item.size, 0); + if (existingBytes + selected.reduce((total, file) => total + file.size, 0) > MAX_COMPOSER_ATTACHMENT_BYTES) { + showToast("附件体积过大", "每轮附件总计不能超过 1.5 MiB", "error"); + return; + } + const next: ComposerAttachment[] = []; + for (const file of selected) { + try { + next.push(await fileToComposerAttachment(file)); + } catch (error) { + showToast("无法添加附件", error instanceof Error ? error.message : String(error), "error"); + } + } + if (!next.length) return; + const combined = [...attachments, ...next]; + if (encodedComposerAttachmentsBytes(combined) > MAX_COMPOSER_ATTACHMENT_BYTES) { + showToast("附件编码后体积过大", "每轮编码后的附件总计不能超过 1.5 MiB", "error"); + return; + } + setAttachments(combined); + } + + async function sendMessage() { + const submission = parseComposerSubmission(input); + if (isGenerating) return; + if (submission.kind === "toggle-plan") { + togglePlanMode(); + return; + } + if (submission.kind === "set-default") { + changeCollaborationMode("default"); + setInput(""); + showToast("已返回默认模式", "下一轮对话生效", "success"); + return; + } + if (submission.kind === "goal" && !submission.objective) { + showToast("请补充目标", "在 /goal 后输入需要持续完成的目标", "error"); + requestAnimationFrame(() => textareaRef.current?.focus()); + return; + } + const goalObjective = submission.kind === "goal" ? submission.objective : ""; + const content = submission.kind === "message" + ? submission.text || (attachments.length ? "请分析这些附件。" : "") + : goalObjective; + if (!content && !attachments.length) return; + const sessionId = currentSessionId || uniqueId("ses"); + const invocationId = uniqueId("resp"); + const approvalModeForTurn = approvalMode; + const controller = new AbortController(); + abortRef.current = controller; + let aggregate: ChatStreamState = { + ...createChatStreamState(invocationId, sessionId), + collaborationMode, + goalObjective, + startedAt: new Date().toISOString(), + }; + setCurrentSessionId(sessionId); + setOptimisticPrompt(content); + setStream(aggregate); + setInput(""); + const turnAttachments = attachments; + setAttachments([]); + + try { + const response = await apiFetch("/v1/responses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + signal: controller.signal, + body: JSON.stringify({ + model, + input: buildResponsesInput(content, turnAttachments), + stream: true, + metadata: { + agent_id: agentId, + session_id: sessionId, + invocation_id: invocationId, + approval_mode: approvalModeForTurn, + collaboration_mode: collaborationMode, + goal_objective: goalObjective || undefined, + }, + }), + }); + if (!response.ok || !response.body) throw new Error(await responseError(response)); + + const decoder = new TextDecoder(); + const parser = createResponseSseParser(event => { + aggregate = reduceChatStreamEvent(aggregate, event); + setStream(aggregate); + }); + const reader = response.body.getReader(); + while (true) { + const { value, done } = await reader.read(); + if (value) parser.push(decoder.decode(value, { stream: !done })); + if (done) break; + } + parser.finish(); + if (aggregate.status === "failed") throw new Error(aggregate.error || "Agent 运行失败"); + await refreshRuns(); + setStream(null); + setOptimisticPrompt(""); + onRunChanged?.(); + } catch (error) { + if (controller.signal.aborted) { + aggregate = { ...aggregate, status: "cancelled" }; + setStream(aggregate); + } else { + const message = error instanceof Error ? error.message : String(error); + aggregate = { ...aggregate, status: "failed", error: message }; + setStream(aggregate); + showToast("运行失败", message, "error"); + } + } finally { + abortRef.current = null; + requestAnimationFrame(() => textareaRef.current?.focus()); + } + } + + function changeApprovalMode(next: ApprovalMode) { + setApprovalMode(next); + localStorage.setItem(approvalModeStorageKey(agentId), next); + } + + async function pauseResponse() { + if (!isGenerating) return; + try { + const response = stream?.status === "streaming" + ? await apiFetch(`/v1/responses/${encodeURIComponent(stream.responseId)}:pause`, { + method: "POST", + credentials: "same-origin", + }) + : await apiFetch(`/api/v1/runs/${encodeURIComponent(persistedActiveRun!.id)}:pause`, { method: "POST" }); + if (!response.ok) throw new Error(await responseError(response)); + setStream(previous => previous ? { ...previous, status: "paused" } : previous); + window.setTimeout(() => { refreshRuns().catch(() => {}); }, 200); + } catch (error) { + showToast("暂停运行失败", error instanceof Error ? error.message : String(error), "error"); + } + } + + async function resumeResponse() { + try { + const response = stream?.status === "paused" + ? await apiFetch(`/v1/responses/${encodeURIComponent(stream.responseId)}:resume`, { + method: "POST", + credentials: "same-origin", + }) + : await apiFetch(`/api/v1/runs/${encodeURIComponent(persistedActiveRun!.id)}:resume`, { method: "POST" }); + if (!response.ok) throw new Error(await responseError(response)); + setStream(previous => previous ? { ...previous, status: "streaming" } : previous); + window.setTimeout(() => { refreshRuns().catch(() => {}); }, 200); + } catch (error) { + showToast("继续运行失败", error instanceof Error ? error.message : String(error), "error"); + } + } + + async function cancelResponse() { + if (!isGenerating) return; + try { + const response = stream + ? await apiFetch(`/v1/responses/${encodeURIComponent(stream.responseId)}/cancel`, { + method: "POST", + credentials: "same-origin", + }) + : await apiFetch(`/api/v1/runs/${encodeURIComponent(persistedActiveRun!.id)}:cancel`, { method: "POST" }); + if (!response.ok) throw new Error(await responseError(response)); + abortRef.current?.abort(); + setStream(previous => previous ? { ...previous, status: "cancelled" } : previous); + window.setTimeout(() => { refreshRuns().catch(() => {}); }, 200); + } catch (error) { + showToast("结束运行失败", error instanceof Error ? error.message : String(error), "error"); + } + } + + async function submitInteraction( + runId: string, + interactionId: string, + name: string, + data: Record, + ) { + if (!runId) throw new Error("运行尚未创建,请稍后重试"); + const response = await apiFetch( + `/api/v1/runs/${encodeURIComponent(runId)}/interactions/${encodeURIComponent(interactionId)}:submit`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, data }), + }, + ); + if (!response.ok) { + const message = await responseError(response); + showToast("提交交互失败", message, "error"); + throw new Error(message); + } + setStream(previous => previous ? { + ...previous, + status: "streaming", + surfaces: previous.surfaces.map(surface => surface.interaction?.id === interactionId + ? { ...surface, interaction: { ...surface.interaction, status: "resolved" } } + : surface), + } : previous); + await refreshRuns(); + } + + async function confirmDeleteSession() { + if (!deleteSessionId) return; + setDeleting(true); + try { + const response = await apiFetch(`/api/v1/sessions/${encodeURIComponent(deleteSessionId)}`, { method: "DELETE" }); + if (!response.ok) throw new Error(await responseError(response)); + if (currentSessionId === deleteSessionId) setCurrentSessionId(""); + setDeleteSessionId(""); + await refreshRuns(); + showToast("会话已删除", "相关运行与 Trace 已从本地工作区移除。"); + } catch (error) { + showToast("删除失败", error instanceof Error ? error.message : String(error), "error"); + } finally { + setDeleting(false); + } + } + + return ( +
+ + +
+
+ +
{agentName}
+ {isGenerating && {activeStatusLabel}} +
+ +
{ + const element = event.currentTarget; + followBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48; + if (currentSessionId) scrollBySessionRef.current.set(currentSessionId, element.scrollTop); + }} + > + {visibleRuns.length === 0 && !stream ? ( +
+ +

开始与 {agentName} 对话

+

消息通过统一的 Responses API 发送;思考、工具调用和结果会在同一条时间线中呈现。

+
+ {["先介绍你的职责、能力和工作边界。", "根据当前上下文给出一个清晰的执行计划。", "列出完成任务还需要我提供的信息。"].map(suggestion => ( + + ))} +
+
+ ) : ( + <> + {displayRuns.map(run => )} + {stream && stream.sessionId === currentSessionId && optimisticPrompt && ( + + )} + + )} +
+ +